mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
Fix concurrency limits and remote branch fetching issues (#788)
* Changes from fix/bug-fixes * feat: Refactor worktree iteration and improve error logging across services * feat: Extract URL/port patterns to module level and fix abort condition * fix: Improve IPv6 loopback handling, select component layout, and terminal UI * feat: Add thinking level defaults and adjust list row padding * Update apps/ui/src/store/app-store.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * feat: Add worktree-aware terminal creation and split options, fix npm security issues from audit * feat: Add tracked remote detection to pull dialog flow * feat: Add merge state tracking to git operations * feat: Improve merge detection and add post-merge action preferences * Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Pass merge detection info to stash reapplication and handle merge state consistently * fix: Call onPulled callback in merge handlers and add validation checks --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,18 @@
|
||||
import { useCallback, useRef, useEffect, useState } from 'react';
|
||||
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
ClipboardPaste,
|
||||
CheckSquare,
|
||||
TextSelect,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StickyModifierKeys, type StickyModifier } from './sticky-modifier-keys';
|
||||
|
||||
/**
|
||||
* ANSI escape sequences for special keys.
|
||||
@@ -37,6 +49,20 @@ interface MobileTerminalShortcutsProps {
|
||||
onSendInput: (data: string) => void;
|
||||
/** Whether the terminal is connected and ready */
|
||||
isConnected: boolean;
|
||||
/** Currently active sticky modifier (Ctrl or Alt) */
|
||||
activeModifier: StickyModifier;
|
||||
/** Callback when sticky modifier is toggled */
|
||||
onModifierChange: (modifier: StickyModifier) => void;
|
||||
/** Callback to copy selected text to clipboard */
|
||||
onCopy?: () => void;
|
||||
/** Callback to paste from clipboard into terminal */
|
||||
onPaste?: () => void;
|
||||
/** Callback to select all terminal content */
|
||||
onSelectAll?: () => void;
|
||||
/** Callback to toggle text selection mode (renders selectable text overlay) */
|
||||
onToggleSelectMode?: () => void;
|
||||
/** Whether text selection mode is currently active */
|
||||
isSelectMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +76,13 @@ interface MobileTerminalShortcutsProps {
|
||||
export function MobileTerminalShortcuts({
|
||||
onSendInput,
|
||||
isConnected,
|
||||
activeModifier,
|
||||
onModifierChange,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onSelectAll,
|
||||
onToggleSelectMode,
|
||||
isSelectMode,
|
||||
}: MobileTerminalShortcutsProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
@@ -135,6 +168,54 @@ export function MobileTerminalShortcuts({
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Sticky modifier keys (Ctrl, Alt) - at the beginning of the bar */}
|
||||
<StickyModifierKeys
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={onModifierChange}
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Clipboard actions */}
|
||||
{onToggleSelectMode && (
|
||||
<IconShortcutButton
|
||||
icon={TextSelect}
|
||||
title={isSelectMode ? 'Exit select mode' : 'Select text'}
|
||||
onPress={onToggleSelectMode}
|
||||
disabled={!isConnected}
|
||||
active={isSelectMode}
|
||||
/>
|
||||
)}
|
||||
{onSelectAll && (
|
||||
<IconShortcutButton
|
||||
icon={CheckSquare}
|
||||
title="Select all"
|
||||
onPress={onSelectAll}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
)}
|
||||
{onCopy && (
|
||||
<IconShortcutButton
|
||||
icon={Copy}
|
||||
title="Copy selection"
|
||||
onPress={onCopy}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
)}
|
||||
{onPaste && (
|
||||
<IconShortcutButton
|
||||
icon={ClipboardPaste}
|
||||
title="Paste from clipboard"
|
||||
onPress={onPaste}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Special keys */}
|
||||
<ShortcutButton
|
||||
label="Esc"
|
||||
@@ -300,3 +381,42 @@ function ArrowButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-based shortcut button for clipboard actions.
|
||||
* Uses a Lucide icon instead of text label for a cleaner mobile UI.
|
||||
*/
|
||||
function IconShortcutButton({
|
||||
icon: Icon,
|
||||
title,
|
||||
onPress,
|
||||
disabled = false,
|
||||
active = false,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'p-2 rounded-md shrink-0 select-none transition-colors min-w-[36px] min-h-[36px] flex items-center justify-center',
|
||||
'active:scale-95 touch-manipulation',
|
||||
active
|
||||
? 'bg-brand-500/20 text-brand-500 ring-1 ring-brand-500/40'
|
||||
: 'bg-muted/80 text-foreground hover:bg-accent',
|
||||
disabled && 'opacity-40 pointer-events-none'
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault(); // Prevent focus stealing from terminal
|
||||
onPress();
|
||||
}}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,14 +51,11 @@ import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
|
||||
import { writeToClipboard, readFromClipboard } from '@/lib/clipboard-utils';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
|
||||
import { MobileTerminalShortcuts } from './mobile-terminal-shortcuts';
|
||||
import {
|
||||
StickyModifierKeys,
|
||||
applyStickyModifier,
|
||||
type StickyModifier,
|
||||
} from './sticky-modifier-keys';
|
||||
import { applyStickyModifier, type StickyModifier } from './sticky-modifier-keys';
|
||||
import { TerminalScriptsDropdown } from './terminal-scripts-dropdown';
|
||||
|
||||
const logger = createLogger('Terminal');
|
||||
@@ -81,6 +78,9 @@ const LARGE_PASTE_WARNING_THRESHOLD = 1024 * 1024; // 1MB - show warning for pas
|
||||
const PASTE_CHUNK_SIZE = 8 * 1024; // 8KB chunks for large pastes
|
||||
const PASTE_CHUNK_DELAY_MS = 10; // Small delay between chunks to prevent overwhelming WebSocket
|
||||
|
||||
// Mobile overlay buffer cap - limit lines read from terminal buffer to avoid DOM blow-up on mobile
|
||||
const MAX_OVERLAY_LINES = 1000; // Maximum number of lines to read for the mobile select-mode overlay
|
||||
|
||||
interface TerminalPanelProps {
|
||||
sessionId: string;
|
||||
authToken: string | null;
|
||||
@@ -157,6 +157,9 @@ export function TerminalPanel({
|
||||
const [isImageDragOver, setIsImageDragOver] = useState(false);
|
||||
const [isProcessingImage, setIsProcessingImage] = useState(false);
|
||||
const hasRunInitialCommandRef = useRef(false);
|
||||
// Long-press timer for mobile context menu
|
||||
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const longPressTouchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
// Tracks whether the connected shell is a Windows shell (PowerShell, cmd, etc.).
|
||||
// Maintained as a ref (not state) so sendCommand can read the current value without
|
||||
// causing unnecessary re-renders or stale closure issues. Set inside ws.onmessage
|
||||
@@ -169,6 +172,10 @@ export function TerminalPanel({
|
||||
const showSearchRef = useRef(false);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
|
||||
// Mobile text selection mode - renders terminal buffer as selectable DOM text
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
const [selectModeText, setSelectModeText] = useState('');
|
||||
|
||||
// Sticky modifier key state (Ctrl or Alt) for the terminal toolbar
|
||||
const [stickyModifier, setStickyModifier] = useState<StickyModifier>(null);
|
||||
const stickyModifierRef = useRef<StickyModifier>(null);
|
||||
@@ -330,9 +337,16 @@ export function TerminalPanel({
|
||||
try {
|
||||
// Strip any ANSI escape codes that might be in the selection
|
||||
const cleanText = stripAnsi(selection);
|
||||
await navigator.clipboard.writeText(cleanText);
|
||||
toast.success('Copied to clipboard');
|
||||
return true;
|
||||
const success = await writeToClipboard(cleanText);
|
||||
if (success) {
|
||||
toast.success('Copied to clipboard');
|
||||
return true;
|
||||
} else {
|
||||
toast.error('Copy failed', {
|
||||
description: 'Could not access clipboard',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Copy failed:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
@@ -399,7 +413,7 @@ export function TerminalPanel({
|
||||
if (!terminal || !wsRef.current) return;
|
||||
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const text = await readFromClipboard();
|
||||
if (!text) {
|
||||
toast.error('Nothing to paste', {
|
||||
description: 'Clipboard is empty',
|
||||
@@ -428,7 +442,9 @@ export function TerminalPanel({
|
||||
toast.error('Paste failed', {
|
||||
description: errorMessage.includes('permission')
|
||||
? 'Clipboard permission denied'
|
||||
: 'Could not read from clipboard',
|
||||
: errorMessage.includes('not supported')
|
||||
? errorMessage
|
||||
: 'Could not read from clipboard',
|
||||
});
|
||||
}
|
||||
}, [sendTextInChunks]);
|
||||
@@ -439,6 +455,45 @@ export function TerminalPanel({
|
||||
xtermRef.current?.selectAll();
|
||||
}, []);
|
||||
|
||||
// Extract terminal buffer text for mobile selection mode overlay
|
||||
const getTerminalBufferText = useCallback((): string => {
|
||||
const terminal = xtermRef.current;
|
||||
if (!terminal) return '';
|
||||
|
||||
const buffer = terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
|
||||
// Cap the number of lines read to MAX_OVERLAY_LINES to avoid blowing up the DOM on mobile
|
||||
const startIndex = Math.max(0, buffer.length - MAX_OVERLAY_LINES);
|
||||
for (let i = startIndex; i < buffer.length; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (line) {
|
||||
lines.push(line.translateToString(true));
|
||||
}
|
||||
}
|
||||
|
||||
// Trim trailing empty lines but keep internal structure
|
||||
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}, []);
|
||||
|
||||
// Toggle mobile text selection mode
|
||||
const toggleSelectMode = useCallback(() => {
|
||||
if (isSelectMode) {
|
||||
setIsSelectMode(false);
|
||||
setSelectModeText('');
|
||||
} else {
|
||||
const text = getTerminalBufferText();
|
||||
// Strip ANSI escape codes for clean display
|
||||
const cleanText = stripAnsi(text);
|
||||
setSelectModeText(cleanText);
|
||||
setIsSelectMode(true);
|
||||
}
|
||||
}, [isSelectMode, getTerminalBufferText]);
|
||||
|
||||
// Clear terminal
|
||||
const clearTerminal = useCallback(() => {
|
||||
xtermRef.current?.clear();
|
||||
@@ -944,17 +999,17 @@ export function TerminalPanel({
|
||||
const otherModKey = isMacRef.current ? event.ctrlKey : event.metaKey;
|
||||
|
||||
// Ctrl+Shift+C / Cmd+Shift+C - Always copy (Linux terminal convention)
|
||||
// Don't preventDefault() — allow the native browser copy to work alongside our custom copy
|
||||
if (modKey && !otherModKey && event.shiftKey && !event.altKey && code === 'KeyC') {
|
||||
event.preventDefault();
|
||||
copySelectionRef.current();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+C / Cmd+C - Copy if text is selected, otherwise send SIGINT
|
||||
// Don't preventDefault() when copying — allow the native browser copy to work alongside our custom copy
|
||||
if (modKey && !otherModKey && !event.shiftKey && !event.altKey && code === 'KeyC') {
|
||||
const hasSelection = terminal.hasSelection();
|
||||
if (hasSelection) {
|
||||
event.preventDefault();
|
||||
copySelectionRef.current();
|
||||
terminal.clearSelection();
|
||||
return false;
|
||||
@@ -964,9 +1019,11 @@ export function TerminalPanel({
|
||||
}
|
||||
|
||||
// Ctrl+V / Cmd+V or Ctrl+Shift+V / Cmd+Shift+V - Paste
|
||||
// Don't preventDefault() — allow the native browser paste to work.
|
||||
// Return false to prevent xterm from sending \x16 (literal next),
|
||||
// but the browser's native paste event will still fire and xterm will
|
||||
// receive the pasted text through its onData handler.
|
||||
if (modKey && !otherModKey && !event.altKey && code === 'KeyV') {
|
||||
event.preventDefault();
|
||||
pasteFromClipboardRef.current();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1014,6 +1071,12 @@ export function TerminalPanel({
|
||||
resizeDebounceRef.current = null;
|
||||
}
|
||||
|
||||
// Clear long-press timer
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Clear search decorations before disposing to prevent visual artifacts
|
||||
if (searchAddonRef.current) {
|
||||
searchAddonRef.current.clearDecorations();
|
||||
@@ -1571,6 +1634,17 @@ export function TerminalPanel({
|
||||
buttons[focusedMenuIndex]?.focus();
|
||||
}, [focusedMenuIndex, contextMenu]);
|
||||
|
||||
// Reset select mode when viewport transitions from mobile to non-mobile.
|
||||
// The select-mode overlay is only rendered when (isSelectMode && isMobile), so if the
|
||||
// viewport becomes non-mobile while isSelectMode is true the overlay disappears but the
|
||||
// state is left dirty with no UI to clear it. Resetting here keeps state consistent.
|
||||
useEffect(() => {
|
||||
if (!isMobile && isSelectMode) {
|
||||
setIsSelectMode(false);
|
||||
setSelectModeText('');
|
||||
}
|
||||
}, [isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Handle right-click context menu with boundary checking
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1602,6 +1676,77 @@ export function TerminalPanel({
|
||||
setContextMenu({ x, y });
|
||||
}, []);
|
||||
|
||||
// Long-press handlers for mobile context menu
|
||||
// On mobile, there's no right-click, so we trigger the context menu on long-press (500ms hold)
|
||||
const LONG_PRESS_DURATION = 500; // ms
|
||||
const LONG_PRESS_MOVE_THRESHOLD = 10; // px - cancel if finger moves more than this
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (!isMobile) return;
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
// Clear any existing timer before creating a new one to avoid orphaned timeouts
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Capture initial touch coordinates into an immutable local snapshot
|
||||
const startPos = { x: touch.clientX, y: touch.clientY };
|
||||
longPressTouchStartRef.current = startPos;
|
||||
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
// Use the locally captured startPos rather than re-reading the ref
|
||||
// Menu dimensions (approximate)
|
||||
const menuWidth = 160;
|
||||
const menuHeight = 152;
|
||||
const padding = 8;
|
||||
|
||||
let x = startPos.x;
|
||||
let y = startPos.y;
|
||||
|
||||
// Boundary checks
|
||||
if (x + menuWidth + padding > window.innerWidth) {
|
||||
x = window.innerWidth - menuWidth - padding;
|
||||
}
|
||||
if (y + menuHeight + padding > window.innerHeight) {
|
||||
y = window.innerHeight - menuHeight - padding;
|
||||
}
|
||||
x = Math.max(padding, x);
|
||||
y = Math.max(padding, y);
|
||||
|
||||
setContextMenu({ x, y });
|
||||
longPressTouchStartRef.current = null;
|
||||
}, LONG_PRESS_DURATION);
|
||||
},
|
||||
[isMobile]
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!longPressTimerRef.current || !longPressTouchStartRef.current) return;
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const dx = touch.clientX - longPressTouchStartRef.current.x;
|
||||
const dy = touch.clientY - longPressTouchStartRef.current.y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) > LONG_PRESS_MOVE_THRESHOLD) {
|
||||
// Finger moved too far, cancel long-press
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
longPressTouchStartRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
longPressTouchStartRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Convert file to base64
|
||||
const fileToBase64 = useCallback((file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -2092,15 +2237,6 @@ export function TerminalPanel({
|
||||
|
||||
<div className="w-px h-3 mx-0.5 bg-border" />
|
||||
|
||||
{/* Sticky modifier keys (Ctrl, Alt) */}
|
||||
<StickyModifierKeys
|
||||
activeModifier={stickyModifier}
|
||||
onModifierChange={handleStickyModifierChange}
|
||||
isConnected={connectionStatus === 'connected'}
|
||||
/>
|
||||
|
||||
<div className="w-px h-3 mx-0.5 bg-border" />
|
||||
|
||||
{/* Split/close buttons */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -2221,24 +2357,116 @@ export function TerminalPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile shortcuts bar - special keys and arrow keys for touch devices */}
|
||||
{/* Mobile shortcuts bar - special keys, clipboard, and arrow keys for touch devices */}
|
||||
{isMobile && (
|
||||
<MobileTerminalShortcuts
|
||||
onSendInput={sendTerminalInput}
|
||||
isConnected={connectionStatus === 'connected'}
|
||||
activeModifier={stickyModifier}
|
||||
onModifierChange={handleStickyModifierChange}
|
||||
onSelectAll={selectAll}
|
||||
onCopy={() => {
|
||||
// On mobile, if nothing is selected, auto-select all before copying.
|
||||
// This provides a convenient "tap to copy all" experience since
|
||||
// touch-based text selection in xterm.js canvas is not possible.
|
||||
const terminal = xtermRef.current;
|
||||
if (terminal && !terminal.hasSelection()) {
|
||||
terminal.selectAll();
|
||||
}
|
||||
copySelectionRef.current();
|
||||
}}
|
||||
onPaste={() => pasteFromClipboardRef.current()}
|
||||
onToggleSelectMode={toggleSelectMode}
|
||||
isSelectMode={isSelectMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Terminal container - uses terminal theme */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 overflow-hidden relative"
|
||||
style={{ backgroundColor: currentTerminalTheme.background }}
|
||||
onContextMenu={handleContextMenu}
|
||||
onDragOver={handleImageDragOver}
|
||||
onDragLeave={handleImageDragLeave}
|
||||
onDrop={handleImageDrop}
|
||||
/>
|
||||
{/* Terminal area wrapper - relative container for the terminal and selection overlay */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
{/* Terminal container - xterm.js mounts here */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundColor: currentTerminalTheme.background }}
|
||||
onContextMenu={handleContextMenu}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
onDragOver={handleImageDragOver}
|
||||
onDragLeave={handleImageDragLeave}
|
||||
onDrop={handleImageDrop}
|
||||
/>
|
||||
|
||||
{/* Mobile text selection overlay - renders terminal buffer as native selectable text.
|
||||
Overlays the canvas so users can use native touch selection on real DOM text.
|
||||
xterm.js renders to a <canvas>, which prevents native text selection on mobile.
|
||||
This overlay shows the same content as real DOM text that supports touch selection. */}
|
||||
{isSelectMode && isMobile && (
|
||||
<div className="absolute inset-0 z-30 flex flex-col">
|
||||
{/* Header bar with copy/done actions */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-brand-500/95 backdrop-blur-sm text-white shrink-0">
|
||||
<span className="text-xs font-medium">Touch & hold to select text</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md bg-white/20 hover:bg-white/30 active:scale-95 transition-all touch-manipulation"
|
||||
onClick={async () => {
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection?.toString();
|
||||
if (selectedText) {
|
||||
const success = await writeToClipboard(selectedText);
|
||||
if (success) {
|
||||
toast.success('Copied to clipboard');
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
} else {
|
||||
const success = await writeToClipboard(selectModeText);
|
||||
if (success) {
|
||||
toast.success('Copied all text to clipboard');
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md bg-white/20 hover:bg-white/30 active:scale-95 transition-all touch-manipulation"
|
||||
onClick={() => {
|
||||
setIsSelectMode(false);
|
||||
setSelectModeText('');
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Scrollable text content matching terminal appearance */}
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
style={
|
||||
{
|
||||
backgroundColor: currentTerminalTheme.background,
|
||||
color: currentTerminalTheme.foreground,
|
||||
fontFamily: getTerminalFontFamily(fontFamily),
|
||||
fontSize: `${fontSize}px`,
|
||||
lineHeight: `${lineHeight || 1.0}`,
|
||||
padding: '12px 16px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
touchAction: 'auto',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{selectModeText || 'No terminal content to select.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Jump to bottom button - shown when scrolled up */}
|
||||
{!isAtBottom && (
|
||||
|
||||
Reference in New Issue
Block a user