feat: Mobile improvements and Add selective file staging and improve branch switching

This commit is contained in:
gsxdsm
2026-02-17 15:20:28 -08:00
parent de021f96bf
commit 7fcf3c1e1f
42 changed files with 2706 additions and 256 deletions

View File

@@ -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>
);
}

View File

@@ -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}