mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
feat: Mobile improvements and Add selective file staging and improve branch switching
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
import { useCallback, useRef, useEffect, useState } from 'react';
|
||||
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* ANSI escape sequences for special keys.
|
||||
* These are what terminal emulators send when these keys are pressed.
|
||||
*/
|
||||
const SPECIAL_KEYS = {
|
||||
escape: '\x1b',
|
||||
tab: '\t',
|
||||
delete: '\x1b[3~',
|
||||
home: '\x1b[H',
|
||||
end: '\x1b[F',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Common Ctrl key combinations sent as control codes.
|
||||
* Ctrl+<char> sends the char code & 0x1f (e.g., Ctrl+C = 0x03).
|
||||
*/
|
||||
const CTRL_KEYS = {
|
||||
'Ctrl+C': '\x03', // Interrupt / SIGINT
|
||||
'Ctrl+Z': '\x1a', // Suspend / SIGTSTP
|
||||
'Ctrl+D': '\x04', // EOF
|
||||
'Ctrl+L': '\x0c', // Clear screen
|
||||
'Ctrl+A': '\x01', // Move to beginning of line
|
||||
'Ctrl+B': '\x02', // Move cursor back (tmux prefix)
|
||||
} as const;
|
||||
|
||||
const ARROW_KEYS = {
|
||||
up: '\x1b[A',
|
||||
down: '\x1b[B',
|
||||
right: '\x1b[C',
|
||||
left: '\x1b[D',
|
||||
} as const;
|
||||
|
||||
interface MobileTerminalControlsProps {
|
||||
/** Callback to send input data to the terminal WebSocket */
|
||||
onSendInput: (data: string) => void;
|
||||
/** Whether the terminal is connected and ready */
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile quick controls bar for terminal interaction on touch devices.
|
||||
* Provides special keys (Escape, Tab, Ctrl+C, etc.) and arrow keys that are
|
||||
* typically unavailable on mobile virtual keyboards.
|
||||
*
|
||||
* Anchored at the top of the terminal panel, above the terminal content.
|
||||
* Can be collapsed to a minimal toggle to maximize terminal space.
|
||||
*/
|
||||
export function MobileTerminalControls({ onSendInput, isConnected }: MobileTerminalControlsProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
// Track repeat interval for arrow key long-press
|
||||
const repeatIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const repeatTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Cleanup repeat timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (repeatIntervalRef.current) clearInterval(repeatIntervalRef.current);
|
||||
if (repeatTimeoutRef.current) clearTimeout(repeatTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearRepeat = useCallback(() => {
|
||||
if (repeatIntervalRef.current) {
|
||||
clearInterval(repeatIntervalRef.current);
|
||||
repeatIntervalRef.current = null;
|
||||
}
|
||||
if (repeatTimeoutRef.current) {
|
||||
clearTimeout(repeatTimeoutRef.current);
|
||||
repeatTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Sends a key sequence to the terminal. */
|
||||
const sendKey = useCallback(
|
||||
(data: string) => {
|
||||
if (!isConnected) return;
|
||||
onSendInput(data);
|
||||
},
|
||||
[isConnected, onSendInput]
|
||||
);
|
||||
|
||||
/** Handles arrow key press with long-press repeat support. */
|
||||
const handleArrowPress = useCallback(
|
||||
(data: string) => {
|
||||
sendKey(data);
|
||||
// Start repeat after 400ms hold, then every 80ms
|
||||
repeatTimeoutRef.current = setTimeout(() => {
|
||||
repeatIntervalRef.current = setInterval(() => {
|
||||
sendKey(data);
|
||||
}, 80);
|
||||
}, 400);
|
||||
},
|
||||
[sendKey]
|
||||
);
|
||||
|
||||
const handleArrowRelease = useCallback(() => {
|
||||
clearRepeat();
|
||||
}, [clearRepeat]);
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="flex items-center justify-center shrink-0 bg-card/95 backdrop-blur-sm border-b border-border">
|
||||
<button
|
||||
className="flex items-center gap-1 px-4 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors touch-manipulation"
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
title="Show quick controls"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
<span>Controls</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 shrink-0 bg-card/95 backdrop-blur-sm border-b border-border overflow-x-auto">
|
||||
{/* Collapse button */}
|
||||
<button
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0 touch-manipulation"
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
title="Hide quick controls"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Special keys */}
|
||||
<ControlButton
|
||||
label="Esc"
|
||||
onPress={() => sendKey(SPECIAL_KEYS.escape)}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ControlButton
|
||||
label="Tab"
|
||||
onPress={() => sendKey(SPECIAL_KEYS.tab)}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Common Ctrl shortcuts */}
|
||||
<ControlButton
|
||||
label="^C"
|
||||
title="Ctrl+C (Interrupt)"
|
||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+C'])}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ControlButton
|
||||
label="^Z"
|
||||
title="Ctrl+Z (Suspend)"
|
||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+Z'])}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ControlButton
|
||||
label="^D"
|
||||
title="Ctrl+D (EOF)"
|
||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+D'])}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ControlButton
|
||||
label="^L"
|
||||
title="Ctrl+L (Clear)"
|
||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+L'])}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ControlButton
|
||||
label="^B"
|
||||
title="Ctrl+B (Back/tmux prefix)"
|
||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+B'])}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Navigation keys */}
|
||||
<ControlButton
|
||||
label="Del"
|
||||
onPress={() => sendKey(SPECIAL_KEYS.delete)}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ControlButton
|
||||
label="Home"
|
||||
onPress={() => sendKey(SPECIAL_KEYS.home)}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ControlButton
|
||||
label="End"
|
||||
onPress={() => sendKey(SPECIAL_KEYS.end)}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Arrow keys with long-press repeat */}
|
||||
<ArrowButton
|
||||
direction="left"
|
||||
onPress={() => handleArrowPress(ARROW_KEYS.left)}
|
||||
onRelease={handleArrowRelease}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ArrowButton
|
||||
direction="down"
|
||||
onPress={() => handleArrowPress(ARROW_KEYS.down)}
|
||||
onRelease={handleArrowRelease}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ArrowButton
|
||||
direction="up"
|
||||
onPress={() => handleArrowPress(ARROW_KEYS.up)}
|
||||
onRelease={handleArrowRelease}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ArrowButton
|
||||
direction="right"
|
||||
onPress={() => handleArrowPress(ARROW_KEYS.right)}
|
||||
onRelease={handleArrowRelease}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual control button for special keys and shortcuts.
|
||||
*/
|
||||
function ControlButton({
|
||||
label,
|
||||
title,
|
||||
onPress,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
title?: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-md text-xs font-medium shrink-0 select-none transition-colors min-w-[36px] min-h-[36px] flex items-center justify-center',
|
||||
'active:scale-95 touch-manipulation',
|
||||
'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}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow key button with long-press repeat support.
|
||||
* Uses pointer events for reliable touch + mouse handling.
|
||||
*/
|
||||
function ArrowButton({
|
||||
direction,
|
||||
onPress,
|
||||
onRelease,
|
||||
disabled = false,
|
||||
}: {
|
||||
direction: 'up' | 'down' | 'left' | 'right';
|
||||
onPress: () => void;
|
||||
onRelease: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const icons = {
|
||||
up: ArrowUp,
|
||||
down: ArrowDown,
|
||||
left: ArrowLeft,
|
||||
right: ArrowRight,
|
||||
};
|
||||
const Icon = icons[direction];
|
||||
|
||||
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',
|
||||
'bg-muted/80 text-foreground hover:bg-accent',
|
||||
disabled && 'opacity-40 pointer-events-none'
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault(); // Prevent focus stealing from terminal
|
||||
onPress();
|
||||
}}
|
||||
onPointerUp={onRelease}
|
||||
onPointerLeave={onRelease}
|
||||
onPointerCancel={onRelease}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,9 @@ 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 { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
|
||||
import { MobileTerminalControls } from './mobile-terminal-controls';
|
||||
|
||||
const logger = createLogger('Terminal');
|
||||
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||
@@ -163,6 +166,12 @@ export function TerminalPanel({
|
||||
const INITIAL_RECONNECT_DELAY = 1000;
|
||||
const [processExitCode, setProcessExitCode] = useState<number | null>(null);
|
||||
|
||||
// Detect mobile viewport for quick controls
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Track virtual keyboard height on mobile to prevent overlap
|
||||
const { keyboardHeight, isKeyboardOpen } = useVirtualKeyboardResize();
|
||||
|
||||
// Get current project for image saving
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
|
||||
@@ -345,6 +354,13 @@ export function TerminalPanel({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Send raw input to terminal via WebSocket (used by mobile quick controls)
|
||||
const sendTerminalInput = useCallback((data: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Paste from clipboard
|
||||
const pasteFromClipboard = useCallback(async () => {
|
||||
const terminal = xtermRef.current;
|
||||
@@ -1722,6 +1738,9 @@ export function TerminalPanel({
|
||||
// Visual feedback when hovering over as drop target
|
||||
isOver && isDropTarget && 'ring-2 ring-green-500 ring-inset'
|
||||
)}
|
||||
style={
|
||||
isMobile && isKeyboardOpen ? { height: `calc(100% - ${keyboardHeight}px)` } : undefined
|
||||
}
|
||||
onClick={onFocus}
|
||||
onKeyDownCapture={handleContainerKeyDownCapture}
|
||||
tabIndex={0}
|
||||
@@ -2138,6 +2157,14 @@ export function TerminalPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile quick controls - special keys and arrow keys for touch devices */}
|
||||
{isMobile && (
|
||||
<MobileTerminalControls
|
||||
onSendInput={sendTerminalInput}
|
||||
isConnected={connectionStatus === 'connected'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Terminal container - uses terminal theme */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
|
||||
Reference in New Issue
Block a user