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:
gsxdsm
2026-02-20 13:48:22 -08:00
committed by GitHub
parent 7df2182818
commit 0a5540c9a2
70 changed files with 4525 additions and 857 deletions

View File

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

View File

@@ -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 &amp; 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 && (