mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-26 21:55:50 +08:00
Compare commits
4 Commits
feat/pendi
...
fix/mobile
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7a0891f56 | ||
|
|
241ed1da54 | ||
|
|
ee002fc3f7 | ||
|
|
c947eaaee5 |
@@ -1,6 +1,5 @@
|
|||||||
import type { ITerminalOptions } from '@xterm/xterm';
|
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 SHELL_RESTART_DELAY_MS = 200;
|
||||||
export const TERMINAL_INIT_DELAY_MS = 100;
|
export const TERMINAL_INIT_DELAY_MS = 100;
|
||||||
export const TERMINAL_RESIZE_DELAY_MS = 50;
|
export const TERMINAL_RESIZE_DELAY_MS = 50;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ type UseShellConnectionOptions = {
|
|||||||
autoConnect: boolean;
|
autoConnect: boolean;
|
||||||
closeSocket: () => void;
|
closeSocket: () => void;
|
||||||
clearTerminalScreen: () => void;
|
clearTerminalScreen: () => void;
|
||||||
setAuthUrl: (nextAuthUrl: string) => void;
|
|
||||||
onOutputRef?: MutableRefObject<(() => void) | null>;
|
onOutputRef?: MutableRefObject<(() => void) | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,7 +48,6 @@ export function useShellConnection({
|
|||||||
autoConnect,
|
autoConnect,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
clearTerminalScreen,
|
clearTerminalScreen,
|
||||||
setAuthUrl,
|
|
||||||
onOutputRef,
|
onOutputRef,
|
||||||
}: UseShellConnectionOptions): UseShellConnectionResult {
|
}: UseShellConnectionOptions): UseShellConnectionResult {
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
@@ -100,14 +98,8 @@ export function useShellConnection({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'auth_url' || message.type === 'url_open') {
|
|
||||||
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
|
|
||||||
if (nextAuthUrl) {
|
|
||||||
setAuthUrl(nextAuthUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef],
|
[handleProcessCompletion, onOutputRef, terminalRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectWebSocket = useCallback(
|
const connectWebSocket = useCallback(
|
||||||
@@ -133,7 +125,6 @@ export function useShellConnection({
|
|||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
setAuthUrl('');
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const currentTerminal = terminalRef.current;
|
const currentTerminal = terminalRef.current;
|
||||||
@@ -196,7 +187,6 @@ export function useShellConnection({
|
|||||||
isPlainShellRef,
|
isPlainShellRef,
|
||||||
selectedProjectRef,
|
selectedProjectRef,
|
||||||
selectedSessionRef,
|
selectedSessionRef,
|
||||||
setAuthUrl,
|
|
||||||
terminalRef,
|
terminalRef,
|
||||||
wsRef,
|
wsRef,
|
||||||
],
|
],
|
||||||
@@ -225,8 +215,7 @@ export function useShellConnection({
|
|||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
forceRestartOnInitRef.current = false;
|
forceRestartOnInitRef.current = false;
|
||||||
setAuthUrl('');
|
}, [clearTerminalScreen, closeSocket]);
|
||||||
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import type { FitAddon } from '@xterm/addon-fit';
|
import type { FitAddon } from '@xterm/addon-fit';
|
||||||
import type { Terminal } from '@xterm/xterm';
|
import type { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
|
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
|
||||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
|
||||||
import { useShellConnection } from './useShellConnection';
|
import { useShellConnection } from './useShellConnection';
|
||||||
import { useShellTerminal } from './useShellTerminal';
|
import { useShellTerminal } from './useShellTerminal';
|
||||||
|
|
||||||
@@ -22,15 +23,11 @@ export function useShellRuntime({
|
|||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
const [authUrl, setAuthUrl] = useState('');
|
|
||||||
const [authUrlVersion, setAuthUrlVersion] = useState(0);
|
|
||||||
|
|
||||||
const selectedProjectRef = useRef(selectedProject);
|
const selectedProjectRef = useRef(selectedProject);
|
||||||
const selectedSessionRef = useRef(selectedSession);
|
const selectedSessionRef = useRef(selectedSession);
|
||||||
const initialCommandRef = useRef(initialCommand);
|
const initialCommandRef = useRef(initialCommand);
|
||||||
const isPlainShellRef = useRef(isPlainShell);
|
const isPlainShellRef = useRef(isPlainShell);
|
||||||
const onProcessCompleteRef = useRef(onProcessComplete);
|
const onProcessCompleteRef = useRef(onProcessComplete);
|
||||||
const authUrlRef = useRef('');
|
|
||||||
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
|
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
|
||||||
|
|
||||||
// Keep mutable values in refs so websocket handlers always read current data.
|
// Keep mutable values in refs so websocket handlers always read current data.
|
||||||
@@ -42,12 +39,6 @@ export function useShellRuntime({
|
|||||||
onProcessCompleteRef.current = onProcessComplete;
|
onProcessCompleteRef.current = onProcessComplete;
|
||||||
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
|
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
|
||||||
|
|
||||||
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
|
|
||||||
authUrlRef.current = nextAuthUrl;
|
|
||||||
setAuthUrl(nextAuthUrl);
|
|
||||||
setAuthUrlVersion((previous) => previous + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeSocket = useCallback(() => {
|
const closeSocket = useCallback(() => {
|
||||||
const activeSocket = wsRef.current;
|
const activeSocket = wsRef.current;
|
||||||
if (!activeSocket) {
|
if (!activeSocket) {
|
||||||
@@ -64,32 +55,6 @@ export function useShellRuntime({
|
|||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
|
|
||||||
if (!url) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const popup = window.open(url, '_blank');
|
|
||||||
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({
|
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
@@ -98,10 +63,6 @@ export function useShellRuntime({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
minimal,
|
minimal,
|
||||||
isRestarting,
|
isRestarting,
|
||||||
initialCommandRef,
|
|
||||||
isPlainShellRef,
|
|
||||||
authUrlRef,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
closeSocket,
|
closeSocket,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,7 +79,6 @@ export function useShellRuntime({
|
|||||||
autoConnect,
|
autoConnect,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
clearTerminalScreen,
|
clearTerminalScreen,
|
||||||
setAuthUrl: setCurrentAuthUrl,
|
|
||||||
onOutputRef,
|
onOutputRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,11 +116,7 @@ export function useShellRuntime({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
authUrl,
|
|
||||||
authUrlVersion,
|
|
||||||
connectToShell,
|
connectToShell,
|
||||||
disconnectFromShell,
|
disconnectFromShell,
|
||||||
openAuthUrlInBrowser,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import { FitAddon } from '@xterm/addon-fit';
|
|||||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||||
import { WebglAddon } from '@xterm/addon-webgl';
|
import { WebglAddon } from '@xterm/addon-webgl';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { Project } from '../../../types/app';
|
import type { Project } from '../../../types/app';
|
||||||
|
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||||
import {
|
import {
|
||||||
CODEX_DEVICE_AUTH_URL,
|
|
||||||
TERMINAL_INIT_DELAY_MS,
|
TERMINAL_INIT_DELAY_MS,
|
||||||
TERMINAL_OPTIONS,
|
TERMINAL_OPTIONS,
|
||||||
TERMINAL_RESIZE_DELAY_MS,
|
TERMINAL_RESIZE_DELAY_MS,
|
||||||
} from '../constants/constants';
|
} from '../constants/constants';
|
||||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
import {
|
||||||
import { isCodexLoginCommand } from '../utils/auth';
|
installMobileTerminalSelection,
|
||||||
|
type MobileTerminalSelectionManager,
|
||||||
|
} from '../utils/mobileTerminalSelection';
|
||||||
import { sendSocketMessage } from '../utils/socket';
|
import { sendSocketMessage } from '../utils/socket';
|
||||||
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
||||||
|
|
||||||
@@ -24,10 +27,6 @@ type UseShellTerminalOptions = {
|
|||||||
selectedProject: Project | null | undefined;
|
selectedProject: Project | null | undefined;
|
||||||
minimal: boolean;
|
minimal: boolean;
|
||||||
isRestarting: boolean;
|
isRestarting: boolean;
|
||||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
|
||||||
isPlainShellRef: MutableRefObject<boolean>;
|
|
||||||
authUrlRef: MutableRefObject<string>;
|
|
||||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
|
||||||
closeSocket: () => void;
|
closeSocket: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,14 +44,11 @@ export function useShellTerminal({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
minimal,
|
minimal,
|
||||||
isRestarting,
|
isRestarting,
|
||||||
initialCommandRef,
|
|
||||||
isPlainShellRef,
|
|
||||||
authUrlRef,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
closeSocket,
|
closeSocket,
|
||||||
}: UseShellTerminalOptions): UseShellTerminalResult {
|
}: UseShellTerminalOptions): UseShellTerminalResult {
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const resizeTimeoutRef = useRef<number | null>(null);
|
const resizeTimeoutRef = useRef<number | null>(null);
|
||||||
|
const mobileSelectionRef = useRef<MobileTerminalSelectionManager | null>(null);
|
||||||
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
|
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
|
||||||
const hasSelectedProject = Boolean(selectedProject);
|
const hasSelectedProject = Boolean(selectedProject);
|
||||||
|
|
||||||
@@ -70,6 +66,11 @@ export function useShellTerminal({
|
|||||||
}, [terminalRef]);
|
}, [terminalRef]);
|
||||||
|
|
||||||
const disposeTerminal = useCallback(() => {
|
const disposeTerminal = useCallback(() => {
|
||||||
|
if (mobileSelectionRef.current) {
|
||||||
|
mobileSelectionRef.current.dispose();
|
||||||
|
mobileSelectionRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (terminalRef.current) {
|
if (terminalRef.current) {
|
||||||
terminalRef.current.dispose();
|
terminalRef.current.dispose();
|
||||||
terminalRef.current = null;
|
terminalRef.current = null;
|
||||||
@@ -80,7 +81,8 @@ export function useShellTerminal({
|
|||||||
}, [fitAddonRef, terminalRef]);
|
}, [fitAddonRef, terminalRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {
|
const terminalContainer = terminalContainerRef.current;
|
||||||
|
if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +104,11 @@ export function useShellTerminal({
|
|||||||
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTerminal.open(terminalContainerRef.current);
|
nextTerminal.open(terminalContainer);
|
||||||
|
mobileSelectionRef.current = installMobileTerminalSelection(
|
||||||
|
nextTerminal,
|
||||||
|
terminalContainer,
|
||||||
|
);
|
||||||
|
|
||||||
const copyTerminalSelection = async () => {
|
const copyTerminalSelection = async () => {
|
||||||
const selection = nextTerminal.getSelection();
|
const selection = nextTerminal.getSelection();
|
||||||
@@ -133,29 +139,9 @@ export function useShellTerminal({
|
|||||||
void copyTextToClipboard(selection);
|
void copyTextToClipboard(selection);
|
||||||
};
|
};
|
||||||
|
|
||||||
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
|
terminalContainer.addEventListener('copy', handleTerminalCopy);
|
||||||
|
|
||||||
nextTerminal.attachCustomKeyEventHandler((event) => {
|
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'
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
void copyAuthUrlToClipboard(activeAuthUrl);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.type === 'keydown' &&
|
event.type === 'keydown' &&
|
||||||
(event.ctrlKey || event.metaKey) &&
|
(event.ctrlKey || event.metaKey) &&
|
||||||
@@ -240,10 +226,10 @@ export function useShellTerminal({
|
|||||||
}, TERMINAL_RESIZE_DELAY_MS);
|
}, TERMINAL_RESIZE_DELAY_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(terminalContainerRef.current);
|
resizeObserver.observe(terminalContainer);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
|
terminalContainer.removeEventListener('copy', handleTerminalCopy);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
if (resizeTimeoutRef.current !== null) {
|
if (resizeTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(resizeTimeoutRef.current);
|
window.clearTimeout(resizeTimeoutRef.current);
|
||||||
@@ -254,16 +240,12 @@ export function useShellTerminal({
|
|||||||
disposeTerminal();
|
disposeTerminal();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
authUrlRef,
|
|
||||||
closeSocket,
|
closeSocket,
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
disposeTerminal,
|
disposeTerminal,
|
||||||
fitAddonRef,
|
fitAddonRef,
|
||||||
initialCommandRef,
|
|
||||||
isPlainShellRef,
|
|
||||||
isRestarting,
|
isRestarting,
|
||||||
minimal,
|
|
||||||
hasSelectedProject,
|
hasSelectedProject,
|
||||||
|
minimal,
|
||||||
selectedProjectKey,
|
selectedProjectKey,
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import type { Terminal } from '@xterm/xterm';
|
|||||||
|
|
||||||
import type { Project, ProjectSession } from '../../../types/app';
|
import type { Project, ProjectSession } from '../../../types/app';
|
||||||
|
|
||||||
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
|
|
||||||
|
|
||||||
export type ShellInitMessage = {
|
export type ShellInitMessage = {
|
||||||
type: 'init';
|
type: 'init';
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
@@ -54,7 +52,6 @@ export type ShellSharedRefs = {
|
|||||||
wsRef: MutableRefObject<WebSocket | null>;
|
wsRef: MutableRefObject<WebSocket | null>;
|
||||||
terminalRef: MutableRefObject<Terminal | null>;
|
terminalRef: MutableRefObject<Terminal | null>;
|
||||||
fitAddonRef: MutableRefObject<FitAddon | null>;
|
fitAddonRef: MutableRefObject<FitAddon | null>;
|
||||||
authUrlRef: MutableRefObject<string>;
|
|
||||||
selectedProjectRef: MutableRefObject<Project | null | undefined>;
|
selectedProjectRef: MutableRefObject<Project | null | undefined>;
|
||||||
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
|
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
|
||||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
initialCommandRef: MutableRefObject<string | null | undefined>;
|
||||||
@@ -69,10 +66,6 @@ export type UseShellRuntimeResult = {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
authUrl: string;
|
|
||||||
authUrlVersion: number;
|
|
||||||
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
||||||
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
||||||
openAuthUrlInBrowser: (url?: string) => boolean;
|
|
||||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,4 @@
|
|||||||
import type { ProjectSession } from '../../../types/app';
|
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 {
|
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -21,4 +8,4 @@ export function getSessionDisplayName(session: ProjectSession | null | undefined
|
|||||||
return session.__provider === 'cursor'
|
return session.__provider === 'cursor'
|
||||||
? session.name || 'Untitled Session'
|
? session.name || 'Untitled Session'
|
||||||
: session.summary || 'New Session';
|
: session.summary || 'New Session';
|
||||||
}
|
}
|
||||||
|
|||||||
637
src/components/shell/utils/mobileTerminalSelection.ts
Normal file
637
src/components/shell/utils/mobileTerminalSelection.ts
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
import type { IDisposable, Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
|
type TerminalCoords = {
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TouchCoords = {
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CellDimensions = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DragHandle = 'start' | 'end';
|
||||||
|
|
||||||
|
type TerminalWithRenderService = Terminal & {
|
||||||
|
_core?: {
|
||||||
|
_renderService?: {
|
||||||
|
dimensions?: {
|
||||||
|
css?: {
|
||||||
|
cell?: {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MobileTerminalSelectionManager = {
|
||||||
|
dispose: () => void;
|
||||||
|
updateHandles: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LONG_PRESS_MS = 600;
|
||||||
|
const MOVE_THRESHOLD_PX = 8;
|
||||||
|
const HANDLE_SIZE_PX = 22;
|
||||||
|
const FINGER_OFFSET_PX = 40;
|
||||||
|
|
||||||
|
function isTouchSelectionEnvironment(): boolean {
|
||||||
|
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
navigator.maxTouchPoints > 0 ||
|
||||||
|
'ontouchstart' in window ||
|
||||||
|
window.matchMedia?.('(pointer: coarse)').matches === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDistance(start: TouchCoords, end: TouchCoords): number {
|
||||||
|
return Math.hypot(end.clientX - start.clientX, end.clientY - start.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShellMobileSelectionCore implements MobileTerminalSelectionManager {
|
||||||
|
private readonly terminal: Terminal;
|
||||||
|
private readonly terminalContent: HTMLElement;
|
||||||
|
private readonly overlay: HTMLDivElement;
|
||||||
|
private readonly startHandle: HTMLDivElement;
|
||||||
|
private readonly endHandle: HTMLDivElement;
|
||||||
|
private readonly disposables: IDisposable[] = [];
|
||||||
|
private readonly originalPosition: string;
|
||||||
|
|
||||||
|
private didSetPosition = false;
|
||||||
|
private isDestroyed = false;
|
||||||
|
private isSelecting = false;
|
||||||
|
private isHandleDragging = false;
|
||||||
|
private dragHandle: DragHandle | null = null;
|
||||||
|
private selectionStart: TerminalCoords | null = null;
|
||||||
|
private selectionEnd: TerminalCoords | null = null;
|
||||||
|
private touchStart: TouchCoords | null = null;
|
||||||
|
private pendingClearTouch: { point: TouchCoords; moved: boolean } | null = null;
|
||||||
|
private tapHoldTimeout: number | null = null;
|
||||||
|
private cellDimensions: CellDimensions = { width: 0, height: 0 };
|
||||||
|
|
||||||
|
constructor(terminal: Terminal, terminalContent: HTMLElement) {
|
||||||
|
this.terminal = terminal;
|
||||||
|
this.terminalContent = terminalContent;
|
||||||
|
this.originalPosition = terminalContent.style.position;
|
||||||
|
|
||||||
|
if (window.getComputedStyle(terminalContent).position === 'static') {
|
||||||
|
terminalContent.style.position = 'relative';
|
||||||
|
this.didSetPosition = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.overlay = this.createSelectionOverlay();
|
||||||
|
this.startHandle = this.createHandle('start');
|
||||||
|
this.endHandle = this.createHandle('end');
|
||||||
|
this.overlay.append(this.startHandle, this.endHandle);
|
||||||
|
this.terminalContent.appendChild(this.overlay);
|
||||||
|
|
||||||
|
this.attachEventListeners();
|
||||||
|
this.updateCellDimensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSelectionOverlay(): HTMLDivElement {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'shell-mobile-selection-overlay';
|
||||||
|
overlay.style.position = 'absolute';
|
||||||
|
overlay.style.inset = '0';
|
||||||
|
overlay.style.overflow = 'hidden';
|
||||||
|
overlay.style.pointerEvents = 'none';
|
||||||
|
overlay.style.zIndex = '30';
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createHandle(type: DragHandle): HTMLDivElement {
|
||||||
|
const handle = document.createElement('div');
|
||||||
|
handle.className = `shell-mobile-selection-handle shell-mobile-selection-handle-${type}`;
|
||||||
|
handle.dataset.handleType = type;
|
||||||
|
handle.style.position = 'absolute';
|
||||||
|
handle.style.width = `${HANDLE_SIZE_PX}px`;
|
||||||
|
handle.style.height = `${HANDLE_SIZE_PX}px`;
|
||||||
|
handle.style.borderRadius = '50%';
|
||||||
|
handle.style.background = '#3b82f6';
|
||||||
|
handle.style.border = '2px solid #fff';
|
||||||
|
handle.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
|
||||||
|
handle.style.display = 'none';
|
||||||
|
handle.style.pointerEvents = 'auto';
|
||||||
|
handle.style.touchAction = 'none';
|
||||||
|
handle.style.zIndex = '31';
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachEventListeners(): void {
|
||||||
|
if (!this.terminal.element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.terminal.element.addEventListener('touchstart', this.onTerminalTouchStart, {
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
this.terminal.element.addEventListener('touchmove', this.onTerminalTouchMove, {
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
this.terminal.element.addEventListener('touchend', this.onTerminalTouchEnd, {
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
this.terminal.element.addEventListener('touchcancel', this.onTerminalTouchCancel, {
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.startHandle.addEventListener('touchstart', this.onHandleTouchStart, { passive: false });
|
||||||
|
this.startHandle.addEventListener('touchmove', this.onHandleTouchMove, { passive: false });
|
||||||
|
this.startHandle.addEventListener('touchend', this.onHandleTouchEnd, { passive: false });
|
||||||
|
this.startHandle.addEventListener('touchcancel', this.onHandleTouchEnd, { passive: false });
|
||||||
|
|
||||||
|
this.endHandle.addEventListener('touchstart', this.onHandleTouchStart, { passive: false });
|
||||||
|
this.endHandle.addEventListener('touchmove', this.onHandleTouchMove, { passive: false });
|
||||||
|
this.endHandle.addEventListener('touchend', this.onHandleTouchEnd, { passive: false });
|
||||||
|
this.endHandle.addEventListener('touchcancel', this.onHandleTouchEnd, { passive: false });
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', this.onDocumentTouchStart, { passive: true });
|
||||||
|
|
||||||
|
this.disposables.push(
|
||||||
|
this.terminal.onSelectionChange(this.onSelectionChange),
|
||||||
|
this.terminal.onResize(this.onTerminalResize),
|
||||||
|
this.terminal.onScroll(this.onTerminalScroll),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onTerminalTouchStart = (event: TouchEvent): void => {
|
||||||
|
if (event.touches.length !== 1) {
|
||||||
|
this.clearTapHoldTimeout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = this.toTouchCoords(event.touches[0]);
|
||||||
|
this.touchStart = touch;
|
||||||
|
|
||||||
|
if (this.isSelecting) {
|
||||||
|
this.pendingClearTouch = { point: touch, moved: false };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearTapHoldTimeout();
|
||||||
|
this.tapHoldTimeout = window.setTimeout(() => {
|
||||||
|
this.tapHoldTimeout = null;
|
||||||
|
this.startSelection(touch);
|
||||||
|
}, LONG_PRESS_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onTerminalTouchMove = (event: TouchEvent): void => {
|
||||||
|
if (event.touches.length !== 1) {
|
||||||
|
this.clearTapHoldTimeout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = this.toTouchCoords(event.touches[0]);
|
||||||
|
const touchStart = this.touchStart;
|
||||||
|
|
||||||
|
if (this.pendingClearTouch) {
|
||||||
|
this.pendingClearTouch.moved =
|
||||||
|
this.pendingClearTouch.moved ||
|
||||||
|
getDistance(this.pendingClearTouch.point, touch) > MOVE_THRESHOLD_PX;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!touchStart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moved = getDistance(touchStart, touch) > MOVE_THRESHOLD_PX;
|
||||||
|
if (moved) {
|
||||||
|
this.clearTapHoldTimeout();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isSelecting && !this.isHandleDragging) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.extendSelection(touch);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onTerminalTouchEnd = (): void => {
|
||||||
|
this.clearTapHoldTimeout();
|
||||||
|
this.touchStart = null;
|
||||||
|
|
||||||
|
if (!this.pendingClearTouch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldClear = this.isSelecting && !this.pendingClearTouch.moved && !this.isHandleDragging;
|
||||||
|
this.pendingClearTouch = null;
|
||||||
|
|
||||||
|
if (shouldClear) {
|
||||||
|
this.clearSelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onTerminalTouchCancel = (): void => {
|
||||||
|
this.clearTapHoldTimeout();
|
||||||
|
this.touchStart = null;
|
||||||
|
this.pendingClearTouch = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
private onHandleTouchStart = (event: TouchEvent): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (event.touches.length !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
this.dragHandle = target.dataset.handleType === 'start' ? 'start' : 'end';
|
||||||
|
this.isHandleDragging = true;
|
||||||
|
this.pendingClearTouch = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
private onHandleTouchMove = (event: TouchEvent): void => {
|
||||||
|
if (!this.isHandleDragging || !this.dragHandle || event.touches.length !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const touch = this.toTouchCoords(event.touches[0]);
|
||||||
|
const adjustedTouch = {
|
||||||
|
clientX: touch.clientX,
|
||||||
|
clientY: touch.clientY - FINGER_OFFSET_PX,
|
||||||
|
};
|
||||||
|
const coords = this.touchToTerminalCoords(adjustedTouch);
|
||||||
|
if (!coords) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dragHandle === 'start') {
|
||||||
|
this.selectionStart = coords;
|
||||||
|
} else {
|
||||||
|
this.selectionEnd = coords;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.swapHandlesIfNeeded();
|
||||||
|
this.updateSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onHandleTouchEnd = (event: TouchEvent): void => {
|
||||||
|
if (!this.isHandleDragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.isHandleDragging = false;
|
||||||
|
this.dragHandle = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
private onSelectionChange = (): void => {
|
||||||
|
if (!this.isSelecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.terminal.hasSelection()) {
|
||||||
|
this.resetSelectionState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateHandles();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onTerminalResize = (): void => {
|
||||||
|
this.updateCellDimensions();
|
||||||
|
this.updateHandles();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onTerminalScroll = (): void => {
|
||||||
|
this.updateHandles();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onDocumentTouchStart = (event: TouchEvent): void => {
|
||||||
|
if (!this.isSelecting || !event.target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.terminalContent.contains(event.target as Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
private startSelection(touch: TouchCoords): void {
|
||||||
|
const coords = this.touchToTerminalCoords(touch);
|
||||||
|
if (!coords) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordBounds = this.getWordBoundsAt(coords);
|
||||||
|
this.selectionStart = wordBounds?.start ?? coords;
|
||||||
|
this.selectionEnd = wordBounds?.end ?? coords;
|
||||||
|
this.isSelecting = true;
|
||||||
|
|
||||||
|
this.updateSelection();
|
||||||
|
this.showHandles();
|
||||||
|
}
|
||||||
|
|
||||||
|
private extendSelection(touch: TouchCoords): void {
|
||||||
|
const coords = this.touchToTerminalCoords(touch);
|
||||||
|
if (!coords) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectionEnd = coords;
|
||||||
|
this.updateSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSelection(): void {
|
||||||
|
if (!this.selectionStart || !this.selectionEnd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = this.getOrderedSelection();
|
||||||
|
const length = this.calculateSelectionLength(start, end);
|
||||||
|
if (length <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.terminal.select(start.col, start.row, length);
|
||||||
|
this.updateHandles();
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateSelectionLength(start: TerminalCoords, end: TerminalCoords): number {
|
||||||
|
if (start.row === end.row) {
|
||||||
|
return end.col - start.col + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (end.row - start.row) * this.terminal.cols - start.col + end.col + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrderedSelection(): { start: TerminalCoords; end: TerminalCoords } {
|
||||||
|
const start = this.selectionStart;
|
||||||
|
const end = this.selectionEnd;
|
||||||
|
if (!start || !end) {
|
||||||
|
throw new Error('Cannot order empty terminal selection');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start.row < end.row || (start.row === end.row && start.col <= end.col)) {
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start: end, end: start };
|
||||||
|
}
|
||||||
|
|
||||||
|
private swapHandlesIfNeeded(): void {
|
||||||
|
if (!this.selectionStart || !this.selectionEnd || !this.dragHandle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = this.getOrderedSelection();
|
||||||
|
if (start === this.selectionStart && end === this.selectionEnd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectionStart = start;
|
||||||
|
this.selectionEnd = end;
|
||||||
|
this.dragHandle = this.dragHandle === 'start' ? 'end' : 'start';
|
||||||
|
}
|
||||||
|
|
||||||
|
private showHandles(): void {
|
||||||
|
this.startHandle.style.display = 'block';
|
||||||
|
this.endHandle.style.display = 'block';
|
||||||
|
this.updateHandles();
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideHandles(): void {
|
||||||
|
this.startHandle.style.display = 'none';
|
||||||
|
this.endHandle.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHandles(): void {
|
||||||
|
if (!this.isSelecting || !this.selectionStart || !this.selectionEnd) {
|
||||||
|
this.hideHandles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = this.getOrderedSelection();
|
||||||
|
const startPosition = this.terminalCoordsToPixels(start);
|
||||||
|
const endPosition = this.terminalCoordsToPixels(end);
|
||||||
|
|
||||||
|
if (startPosition) {
|
||||||
|
this.startHandle.style.display = 'block';
|
||||||
|
this.startHandle.style.left = `${startPosition.x - HANDLE_SIZE_PX / 2}px`;
|
||||||
|
this.startHandle.style.top = `${startPosition.y + this.cellDimensions.height + 4}px`;
|
||||||
|
} else {
|
||||||
|
this.startHandle.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endPosition) {
|
||||||
|
this.endHandle.style.display = 'block';
|
||||||
|
this.endHandle.style.left = `${endPosition.x + this.cellDimensions.width - HANDLE_SIZE_PX / 2}px`;
|
||||||
|
this.endHandle.style.top = `${endPosition.y + this.cellDimensions.height + 4}px`;
|
||||||
|
} else {
|
||||||
|
this.endHandle.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSelection(): void {
|
||||||
|
this.terminal.clearSelection();
|
||||||
|
this.resetSelectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetSelectionState(): void {
|
||||||
|
this.isSelecting = false;
|
||||||
|
this.isHandleDragging = false;
|
||||||
|
this.dragHandle = null;
|
||||||
|
this.selectionStart = null;
|
||||||
|
this.selectionEnd = null;
|
||||||
|
this.pendingClearTouch = null;
|
||||||
|
this.touchStart = null;
|
||||||
|
this.hideHandles();
|
||||||
|
this.clearTapHoldTimeout();
|
||||||
|
}
|
||||||
|
|
||||||
|
private touchToTerminalCoords(touch: TouchCoords): TerminalCoords | null {
|
||||||
|
const screenElement = this.getTerminalScreenElement();
|
||||||
|
if (!screenElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = screenElement.getBoundingClientRect();
|
||||||
|
const x = touch.clientX - rect.left;
|
||||||
|
const y = touch.clientY - rect.top;
|
||||||
|
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateCellDimensions();
|
||||||
|
if (!this.cellDimensions.width || !this.cellDimensions.height) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const col = clamp(Math.floor(x / this.cellDimensions.width), 0, this.terminal.cols - 1);
|
||||||
|
const row = Math.floor(y / this.cellDimensions.height) + this.terminal.buffer.active.viewportY;
|
||||||
|
|
||||||
|
return {
|
||||||
|
col,
|
||||||
|
row: Math.max(0, row),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private terminalCoordsToPixels(coords: TerminalCoords): { x: number; y: number } | null {
|
||||||
|
const screenElement = this.getTerminalScreenElement();
|
||||||
|
if (!screenElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateCellDimensions();
|
||||||
|
|
||||||
|
const visibleRow = coords.row - this.terminal.buffer.active.viewportY;
|
||||||
|
if (visibleRow < 0 || visibleRow >= this.terminal.rows) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenRect = screenElement.getBoundingClientRect();
|
||||||
|
const containerRect = this.terminalContent.getBoundingClientRect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: screenRect.left - containerRect.left + coords.col * this.cellDimensions.width,
|
||||||
|
y: screenRect.top - containerRect.top + visibleRow * this.cellDimensions.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCellDimensions(): void {
|
||||||
|
const renderCell = (this.terminal as TerminalWithRenderService)._core?._renderService
|
||||||
|
?.dimensions?.css?.cell;
|
||||||
|
if (renderCell?.width && renderCell.height) {
|
||||||
|
this.cellDimensions = {
|
||||||
|
width: renderCell.width,
|
||||||
|
height: renderCell.height,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenElement = this.getTerminalScreenElement();
|
||||||
|
const rect = screenElement?.getBoundingClientRect();
|
||||||
|
if (!rect || !this.terminal.cols || !this.terminal.rows) {
|
||||||
|
this.cellDimensions = { width: 0, height: 0 };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cellDimensions = {
|
||||||
|
width: rect.width / this.terminal.cols,
|
||||||
|
height: rect.height / this.terminal.rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getWordBoundsAt(coords: TerminalCoords): {
|
||||||
|
start: TerminalCoords;
|
||||||
|
end: TerminalCoords;
|
||||||
|
} | null {
|
||||||
|
const line = this.terminal.buffer.active.getLine(coords.row);
|
||||||
|
if (!line) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineText = line.translateToString(false);
|
||||||
|
if (!lineText || coords.col >= lineText.length || /\s/.test(lineText[coords.col])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let startCol = coords.col;
|
||||||
|
let endCol = coords.col;
|
||||||
|
|
||||||
|
while (startCol > 0 && !/\s/.test(lineText[startCol - 1])) {
|
||||||
|
startCol--;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (endCol < lineText.length - 1 && !/\s/.test(lineText[endCol + 1])) {
|
||||||
|
endCol++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: { row: coords.row, col: startCol },
|
||||||
|
end: { row: coords.row, col: endCol },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTerminalScreenElement(): HTMLElement | null {
|
||||||
|
return (
|
||||||
|
this.terminal.element?.querySelector<HTMLElement>('.xterm-screen') ??
|
||||||
|
this.terminal.element ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toTouchCoords(touch: Touch): TouchCoords {
|
||||||
|
return {
|
||||||
|
clientX: touch.clientX,
|
||||||
|
clientY: touch.clientY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTapHoldTimeout(): void {
|
||||||
|
if (this.tapHoldTimeout === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearTimeout(this.tapHoldTimeout);
|
||||||
|
this.tapHoldTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.isDestroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isDestroyed = true;
|
||||||
|
this.clearTapHoldTimeout();
|
||||||
|
|
||||||
|
this.terminal.element?.removeEventListener('touchstart', this.onTerminalTouchStart);
|
||||||
|
this.terminal.element?.removeEventListener('touchmove', this.onTerminalTouchMove);
|
||||||
|
this.terminal.element?.removeEventListener('touchend', this.onTerminalTouchEnd);
|
||||||
|
this.terminal.element?.removeEventListener('touchcancel', this.onTerminalTouchCancel);
|
||||||
|
|
||||||
|
this.startHandle.removeEventListener('touchstart', this.onHandleTouchStart);
|
||||||
|
this.startHandle.removeEventListener('touchmove', this.onHandleTouchMove);
|
||||||
|
this.startHandle.removeEventListener('touchend', this.onHandleTouchEnd);
|
||||||
|
this.startHandle.removeEventListener('touchcancel', this.onHandleTouchEnd);
|
||||||
|
|
||||||
|
this.endHandle.removeEventListener('touchstart', this.onHandleTouchStart);
|
||||||
|
this.endHandle.removeEventListener('touchmove', this.onHandleTouchMove);
|
||||||
|
this.endHandle.removeEventListener('touchend', this.onHandleTouchEnd);
|
||||||
|
this.endHandle.removeEventListener('touchcancel', this.onHandleTouchEnd);
|
||||||
|
|
||||||
|
document.removeEventListener('touchstart', this.onDocumentTouchStart);
|
||||||
|
this.disposables.forEach((disposable) => disposable.dispose());
|
||||||
|
this.disposables.length = 0;
|
||||||
|
|
||||||
|
this.overlay.remove();
|
||||||
|
|
||||||
|
if (this.didSetPosition) {
|
||||||
|
this.terminalContent.style.position = this.originalPosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installMobileTerminalSelection(
|
||||||
|
terminal: Terminal,
|
||||||
|
terminalContent: HTMLElement,
|
||||||
|
): MobileTerminalSelectionManager | null {
|
||||||
|
if (!isTouchSelectionEnvironment() || !terminal.element) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ShellMobileSelectionCore(terminal, terminalContent);
|
||||||
|
}
|
||||||
@@ -59,12 +59,8 @@ export default function Shell({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
authUrl,
|
|
||||||
authUrlVersion,
|
|
||||||
connectToShell,
|
connectToShell,
|
||||||
disconnectFromShell,
|
disconnectFromShell,
|
||||||
openAuthUrlInBrowser,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
} = useShellRuntime({
|
} = useShellRuntime({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -243,15 +239,7 @@ export default function Shell({
|
|||||||
if (minimal) {
|
if (minimal) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ShellMinimalView
|
<ShellMinimalView terminalContainerRef={terminalContainerRef} />
|
||||||
terminalContainerRef={terminalContainerRef}
|
|
||||||
authUrl={authUrl}
|
|
||||||
authUrlVersion={authUrlVersion}
|
|
||||||
initialCommand={initialCommand}
|
|
||||||
isConnected={isConnected}
|
|
||||||
openAuthUrlInBrowser={openAuthUrlInBrowser}
|
|
||||||
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
|
|
||||||
/>
|
|
||||||
<TerminalShortcutsPanel
|
<TerminalShortcutsPanel
|
||||||
wsRef={wsRef}
|
wsRef={wsRef}
|
||||||
terminalRef={terminalRef}
|
terminalRef={terminalRef}
|
||||||
|
|||||||
@@ -1,45 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
import type { AuthCopyStatus } from '../../types/types';
|
|
||||||
import { resolveAuthUrlForDisplay } from '../../utils/auth';
|
|
||||||
|
|
||||||
type ShellMinimalViewProps = {
|
type ShellMinimalViewProps = {
|
||||||
terminalContainerRef: RefObject<HTMLDivElement>;
|
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({
|
export default function ShellMinimalView({
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
authUrl,
|
|
||||||
authUrlVersion,
|
|
||||||
initialCommand,
|
|
||||||
isConnected,
|
|
||||||
openAuthUrlInBrowser,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
}: ShellMinimalViewProps) {
|
}: 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 (
|
return (
|
||||||
<div className="relative h-full w-full bg-gray-900">
|
<div className="relative h-full w-full bg-gray-900">
|
||||||
<div
|
<div
|
||||||
@@ -47,67 +14,6 @@ export default function ShellMinimalView({
|
|||||||
className="h-full w-full focus:outline-none"
|
className="h-full w-full focus:outline-none"
|
||||||
style={{ 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user