mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
feat: Mobile improvements and Add selective file staging and improve branch switching
This commit is contained in:
@@ -62,7 +62,12 @@ import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialo
|
||||
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
|
||||
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
|
||||
import { WorktreePanel } from './board-view/worktree-panel';
|
||||
import type { PRInfo, WorktreeInfo, MergeConflictInfo } from './board-view/worktree-panel/types';
|
||||
import type {
|
||||
PRInfo,
|
||||
WorktreeInfo,
|
||||
MergeConflictInfo,
|
||||
BranchSwitchConflictInfo,
|
||||
} from './board-view/worktree-panel/types';
|
||||
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
|
||||
import {
|
||||
useBoardFeatures,
|
||||
@@ -1015,6 +1020,56 @@ export function BoardView() {
|
||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||
);
|
||||
|
||||
// Handler called when branch switch stash reapply causes merge conflicts
|
||||
const handleBranchSwitchConflict = useCallback(
|
||||
async (conflictInfo: BranchSwitchConflictInfo) => {
|
||||
const description = `Resolve merge conflicts that occurred when switching from "${conflictInfo.previousBranch}" to "${conflictInfo.branchName}". Local changes were stashed before switching and reapplying them caused conflicts. Please resolve all merge conflicts, ensure the code compiles and tests pass.`;
|
||||
|
||||
// Create the feature
|
||||
const featureData = {
|
||||
title: `Resolve Stash Conflicts: switch to ${conflictInfo.branchName}`,
|
||||
category: 'Maintenance',
|
||||
description,
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: 'opus' as const,
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: conflictInfo.branchName,
|
||||
workMode: 'custom' as const,
|
||||
priority: 1,
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
|
||||
// Capture existing feature IDs before adding
|
||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||
try {
|
||||
await handleAddFeature(featureData);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create branch switch conflict resolution feature:', error);
|
||||
toast.error('Failed to create feature', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the newly created feature by looking for an ID that wasn't in the original set
|
||||
const latestFeatures = useAppStore.getState().features;
|
||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
||||
|
||||
if (newFeature) {
|
||||
await handleStartImplementation(newFeature);
|
||||
} else {
|
||||
logger.error('Could not find newly created feature to start it automatically.');
|
||||
toast.error('Failed to auto-start feature', {
|
||||
description: 'The feature was created but could not be started automatically.',
|
||||
});
|
||||
}
|
||||
},
|
||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||
);
|
||||
|
||||
// Handler for "Make" button - creates a feature and immediately starts it
|
||||
const handleAddAndStartFeature = useCallback(
|
||||
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
|
||||
@@ -1454,6 +1509,7 @@ export function BoardView() {
|
||||
onAddressPRComments={handleAddressPRComments}
|
||||
onResolveConflicts={handleResolveConflicts}
|
||||
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
||||
onBranchSwitchConflict={handleBranchSwitchConflict}
|
||||
onBranchDeletedDuringMerge={(branchName) => {
|
||||
// Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog)
|
||||
hookFeatures.forEach((feature) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,11 +10,24 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitCommit, Sparkles } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
GitCommit,
|
||||
Sparkles,
|
||||
FilePlus,
|
||||
FileX,
|
||||
FilePen,
|
||||
FileText,
|
||||
File,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -31,6 +44,229 @@ interface CommitWorktreeDialogProps {
|
||||
onCommitted: () => void;
|
||||
}
|
||||
|
||||
interface ParsedDiffHunk {
|
||||
header: string;
|
||||
lines: {
|
||||
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ParsedFileDiff {
|
||||
filePath: string;
|
||||
hunks: ParsedDiffHunk[];
|
||||
isNew?: boolean;
|
||||
isDeleted?: boolean;
|
||||
isRenamed?: boolean;
|
||||
}
|
||||
|
||||
const getFileIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
case '?':
|
||||
return <FilePlus className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />;
|
||||
case 'D':
|
||||
return <FileX className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />;
|
||||
case 'M':
|
||||
case 'U':
|
||||
return <FilePen className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />;
|
||||
case 'R':
|
||||
case 'C':
|
||||
return <File className="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />;
|
||||
default:
|
||||
return <FileText className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
return 'Added';
|
||||
case '?':
|
||||
return 'Untracked';
|
||||
case 'D':
|
||||
return 'Deleted';
|
||||
case 'M':
|
||||
return 'Modified';
|
||||
case 'U':
|
||||
return 'Updated';
|
||||
case 'R':
|
||||
return 'Renamed';
|
||||
case 'C':
|
||||
return 'Copied';
|
||||
default:
|
||||
return 'Changed';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
case '?':
|
||||
return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||
case 'D':
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||
case 'M':
|
||||
case 'U':
|
||||
return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
|
||||
case 'R':
|
||||
case 'C':
|
||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground border-border';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse unified diff format into structured data
|
||||
*/
|
||||
function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
if (!diffText) return [];
|
||||
|
||||
const files: ParsedFileDiff[] = [];
|
||||
const lines = diffText.split('\n');
|
||||
let currentFile: ParsedFileDiff | null = null;
|
||||
let currentHunk: ParsedDiffHunk | null = null;
|
||||
let oldLineNum = 0;
|
||||
let newLineNum = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('diff --git')) {
|
||||
if (currentFile) {
|
||||
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||
files.push(currentFile);
|
||||
}
|
||||
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
|
||||
currentFile = {
|
||||
filePath: match ? match[2] : 'unknown',
|
||||
hunks: [],
|
||||
};
|
||||
currentHunk = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('new file mode')) {
|
||||
if (currentFile) currentFile.isNew = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('deleted file mode')) {
|
||||
if (currentFile) currentFile.isDeleted = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('rename from') || line.startsWith('rename to')) {
|
||||
if (currentFile) currentFile.isRenamed = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('@@')) {
|
||||
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
|
||||
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
|
||||
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
|
||||
currentHunk = {
|
||||
header: line,
|
||||
lines: [{ type: 'header', content: line }],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
if (line.startsWith('+')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'addition',
|
||||
content: line.substring(1),
|
||||
lineNumber: { new: newLineNum },
|
||||
});
|
||||
newLineNum++;
|
||||
} else if (line.startsWith('-')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'deletion',
|
||||
content: line.substring(1),
|
||||
lineNumber: { old: oldLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
currentHunk.lines.push({
|
||||
type: 'context',
|
||||
content: line.substring(1) || '',
|
||||
lineNumber: { old: oldLineNum, new: newLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFile) {
|
||||
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||
files.push(currentFile);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function DiffLine({
|
||||
type,
|
||||
content,
|
||||
lineNumber,
|
||||
}: {
|
||||
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}) {
|
||||
const bgClass = {
|
||||
context: 'bg-transparent',
|
||||
addition: 'bg-green-500/10',
|
||||
deletion: 'bg-red-500/10',
|
||||
header: 'bg-blue-500/10',
|
||||
};
|
||||
|
||||
const textClass = {
|
||||
context: 'text-foreground-secondary',
|
||||
addition: 'text-green-400',
|
||||
deletion: 'text-red-400',
|
||||
header: 'text-blue-400',
|
||||
};
|
||||
|
||||
const prefix = {
|
||||
context: ' ',
|
||||
addition: '+',
|
||||
deletion: '-',
|
||||
header: '',
|
||||
};
|
||||
|
||||
if (type === 'header') {
|
||||
return (
|
||||
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex font-mono text-xs', bgClass[type])}>
|
||||
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
|
||||
{lineNumber?.old ?? ''}
|
||||
</span>
|
||||
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
|
||||
{lineNumber?.new ?? ''}
|
||||
</span>
|
||||
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
|
||||
{prefix[type]}
|
||||
</span>
|
||||
<span className={cn('flex-1 px-1.5 whitespace-pre-wrap break-all', textClass[type])}>
|
||||
{content || '\u00A0'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommitWorktreeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -43,8 +279,85 @@ export function CommitWorktreeDialog({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
|
||||
|
||||
// File selection state
|
||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
||||
const [diffContent, setDiffContent] = useState('');
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
||||
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
|
||||
|
||||
// Parse diffs
|
||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||
|
||||
// Create a map of file path to parsed diff for quick lookup
|
||||
const diffsByFile = useMemo(() => {
|
||||
const map = new Map<string, ParsedFileDiff>();
|
||||
for (const diff of parsedDiffs) {
|
||||
map.set(diff.filePath, diff);
|
||||
}
|
||||
return map;
|
||||
}, [parsedDiffs]);
|
||||
|
||||
// Load diffs when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
setIsLoadingDiffs(true);
|
||||
setFiles([]);
|
||||
setDiffContent('');
|
||||
setSelectedFiles(new Set());
|
||||
setExpandedFile(null);
|
||||
|
||||
const loadDiffs = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.git?.getDiffs) {
|
||||
const result = await api.git.getDiffs(worktree.path);
|
||||
if (result.success) {
|
||||
const fileList = result.files ?? [];
|
||||
setFiles(fileList);
|
||||
setDiffContent(result.diff ?? '');
|
||||
// Select all files by default
|
||||
setSelectedFiles(new Set(fileList.map((f) => f.path)));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load diffs for commit dialog:', err);
|
||||
} finally {
|
||||
setIsLoadingDiffs(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDiffs();
|
||||
}
|
||||
}, [open, worktree]);
|
||||
|
||||
const handleToggleFile = useCallback((filePath: string) => {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(filePath)) {
|
||||
next.delete(filePath);
|
||||
} else {
|
||||
next.add(filePath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleAll = useCallback(() => {
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.size === files.length) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(files.map((f) => f.path));
|
||||
});
|
||||
}, [files]);
|
||||
|
||||
const handleFileClick = useCallback((filePath: string) => {
|
||||
setExpandedFile((prev) => (prev === filePath ? null : filePath));
|
||||
}, []);
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!worktree || !message.trim()) return;
|
||||
if (!worktree || !message.trim() || selectedFiles.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
@@ -55,7 +368,12 @@ export function CommitWorktreeDialog({
|
||||
setError('Worktree API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.commit(worktree.path, message);
|
||||
|
||||
// Pass selected files if not all files are selected
|
||||
const filesToCommit =
|
||||
selectedFiles.size === files.length ? undefined : Array.from(selectedFiles);
|
||||
|
||||
const result = await api.worktree.commit(worktree.path, message, filesToCommit);
|
||||
|
||||
if (result.success && result.result) {
|
||||
if (result.result.committed) {
|
||||
@@ -81,8 +399,14 @@ export function CommitWorktreeDialog({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Prevent commit while loading or while AI is generating a message
|
||||
if (e.key === 'Enter' && e.metaKey && !isLoading && !isGenerating && message.trim()) {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
e.metaKey &&
|
||||
!isLoading &&
|
||||
!isGenerating &&
|
||||
message.trim() &&
|
||||
selectedFiles.size > 0
|
||||
) {
|
||||
handleCommit();
|
||||
}
|
||||
};
|
||||
@@ -94,7 +418,6 @@ export function CommitWorktreeDialog({
|
||||
setMessage('');
|
||||
setError(null);
|
||||
|
||||
// Only generate AI commit message if enabled
|
||||
if (!enableAiCommitMessages) {
|
||||
return;
|
||||
}
|
||||
@@ -119,13 +442,11 @@ export function CommitWorktreeDialog({
|
||||
if (result.success && result.message) {
|
||||
setMessage(result.message);
|
||||
} else {
|
||||
// Don't show error toast, just log it and leave message empty
|
||||
console.warn('Failed to generate commit message:', result.error);
|
||||
setMessage('');
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
// Don't show error toast for generation failures
|
||||
console.warn('Error generating commit message:', err);
|
||||
setMessage('');
|
||||
} finally {
|
||||
@@ -145,9 +466,11 @@ export function CommitWorktreeDialog({
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
const allSelected = selectedFiles.size === files.length && files.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitCommit className="w-5 h-5" />
|
||||
@@ -156,17 +479,151 @@ export function CommitWorktreeDialog({
|
||||
<DialogDescription>
|
||||
Commit changes in the{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree.
|
||||
{worktree.changedFilesCount && (
|
||||
<span className="ml-1">
|
||||
({worktree.changedFilesCount} file
|
||||
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
|
||||
{/* File Selection */}
|
||||
<div className="flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
Files to commit
|
||||
{isLoadingDiffs ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground font-normal">
|
||||
({selectedFiles.size}/{files.length} selected)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{files.length > 0 && (
|
||||
<button
|
||||
onClick={handleToggleAll}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{allSelected ? 'Deselect all' : 'Select all'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoadingDiffs ? (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<span className="text-sm">Loading changes...</span>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
|
||||
<span className="text-sm">No changes detected</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto scrollbar-visible">
|
||||
{files.map((file) => {
|
||||
const isChecked = selectedFiles.has(file.path);
|
||||
const isExpanded = expandedFile === file.path;
|
||||
const fileDiff = diffsByFile.get(file.path);
|
||||
const additions = fileDiff
|
||||
? fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
|
||||
0
|
||||
)
|
||||
: 0;
|
||||
const deletions = fileDiff
|
||||
? fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
|
||||
0
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={file.path} className="border-b border-border last:border-b-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group',
|
||||
isExpanded && 'bg-accent/30'
|
||||
)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => handleToggleFile(file.path)}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
{/* Clickable file row to show diff */}
|
||||
<button
|
||||
onClick={() => handleFileClick(file.path)}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{getFileIcon(file.status)}
|
||||
<span className="text-xs font-mono truncate flex-1 text-foreground">
|
||||
{file.path}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
|
||||
getStatusBadgeColor(file.status)
|
||||
)}
|
||||
>
|
||||
{getStatusLabel(file.status)}
|
||||
</span>
|
||||
{additions > 0 && (
|
||||
<span className="text-[10px] text-green-400 flex-shrink-0">
|
||||
+{additions}
|
||||
</span>
|
||||
)}
|
||||
{deletions > 0 && (
|
||||
<span className="text-[10px] text-red-400 flex-shrink-0">
|
||||
-{deletions}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded diff view */}
|
||||
{isExpanded && fileDiff && (
|
||||
<div className="bg-background border-t border-border max-h-[200px] overflow-y-auto scrollbar-visible">
|
||||
{fileDiff.hunks.map((hunk, hunkIndex) => (
|
||||
<div
|
||||
key={hunkIndex}
|
||||
className="border-b border-border-glass last:border-b-0"
|
||||
>
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<DiffLine
|
||||
key={lineIndex}
|
||||
type={line.type}
|
||||
content={line.content}
|
||||
lineNumber={line.lineNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && !fileDiff && (
|
||||
<div className="px-4 py-3 text-xs text-muted-foreground bg-background border-t border-border">
|
||||
{file.status === '?' ? (
|
||||
<span>New file - diff preview not available</span>
|
||||
) : file.status === 'D' ? (
|
||||
<span>File deleted</span>
|
||||
) : (
|
||||
<span>Diff content not available</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commit Message */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="commit-message" className="flex items-center gap-2">
|
||||
Commit Message
|
||||
{isGenerating && (
|
||||
@@ -187,7 +644,7 @@ export function CommitWorktreeDialog({
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="min-h-[100px] font-mono text-sm"
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
autoFocus
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
@@ -207,7 +664,10 @@ export function CommitWorktreeDialog({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
|
||||
<Button
|
||||
onClick={handleCommit}
|
||||
disabled={isLoading || isGenerating || !message.trim() || selectedFiles.size === 0}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
@@ -217,6 +677,9 @@ export function CommitWorktreeDialog({
|
||||
<>
|
||||
<GitCommit className="w-4 h-4 mr-2" />
|
||||
Commit
|
||||
{selectedFiles.size > 0 && selectedFiles.size < files.length
|
||||
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
|
||||
: ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function ViewWorktreeChangesDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
|
||||
@@ -93,6 +93,7 @@ export function useBoardActions({
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
getAutoModeState,
|
||||
getMaxConcurrencyForWorktree,
|
||||
} = useAppStore();
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
@@ -566,7 +567,11 @@ export function useBoardActions({
|
||||
const featureWorktreeState = currentProject
|
||||
? getAutoModeState(currentProject.id, featureBranchName)
|
||||
: null;
|
||||
const featureMaxConcurrency = featureWorktreeState?.maxConcurrency ?? autoMode.maxConcurrency;
|
||||
// Use getMaxConcurrencyForWorktree which correctly falls back to global maxConcurrency
|
||||
// instead of autoMode.maxConcurrency which only falls back to DEFAULT_MAX_CONCURRENCY (1)
|
||||
const featureMaxConcurrency = currentProject
|
||||
? getMaxConcurrencyForWorktree(currentProject.id, featureBranchName)
|
||||
: autoMode.maxConcurrency;
|
||||
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
|
||||
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
|
||||
|
||||
@@ -647,6 +652,7 @@ export function useBoardActions({
|
||||
handleRunFeature,
|
||||
currentProject,
|
||||
getAutoModeState,
|
||||
getMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -191,7 +191,7 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-4 right-4 z-50 flex flex-col gap-2',
|
||||
'max-h-[calc(100vh-120px)] overflow-y-auto',
|
||||
'max-h-[calc(100dvh-120px)] overflow-y-auto',
|
||||
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { GitBranch, GitBranchPlus, Check, Search } from 'lucide-react';
|
||||
import { GitBranch, GitBranchPlus, Check, Search, Globe } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, BranchInfo } from '../types';
|
||||
@@ -42,6 +43,43 @@ export function BranchSwitchDropdown({
|
||||
onSwitchBranch,
|
||||
onCreateBranch,
|
||||
}: BranchSwitchDropdownProps) {
|
||||
// Separate local and remote branches, filtering out bare remotes without a branch
|
||||
const { localBranches, remoteBranches } = useMemo(() => {
|
||||
const local: BranchInfo[] = [];
|
||||
const remote: BranchInfo[] = [];
|
||||
for (const branch of filteredBranches) {
|
||||
if (branch.isRemote) {
|
||||
// Skip bare remote refs without a branch name (e.g. "origin" by itself)
|
||||
if (!branch.name.includes('/')) continue;
|
||||
remote.push(branch);
|
||||
} else {
|
||||
local.push(branch);
|
||||
}
|
||||
}
|
||||
return { localBranches: local, remoteBranches: remote };
|
||||
}, [filteredBranches]);
|
||||
|
||||
const renderBranchItem = (branch: BranchInfo) => {
|
||||
const isCurrent = branch.name === worktree.branch;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={branch.name}
|
||||
onClick={() => onSwitchBranch(worktree, branch.name)}
|
||||
disabled={isSwitching || isCurrent}
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{isCurrent ? (
|
||||
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
||||
) : branch.isRemote ? (
|
||||
<Globe className="w-3.5 h-3.5 mr-2 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<span className="w-3.5 mr-2 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -60,7 +98,7 @@ export function BranchSwitchDropdown({
|
||||
<GitBranch className={standalone ? 'w-3.5 h-3.5' : 'w-3 h-3'} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5">
|
||||
@@ -73,13 +111,13 @@ export function BranchSwitchDropdown({
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
onKeyPress={(e) => e.stopPropagation()}
|
||||
className="h-7 pl-7 text-xs"
|
||||
className="h-7 pl-7 text-base md:text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden">
|
||||
{isLoadingBranches ? (
|
||||
<DropdownMenuItem disabled className="text-xs">
|
||||
<Spinner size="xs" className="mr-2" />
|
||||
@@ -90,21 +128,28 @@ export function BranchSwitchDropdown({
|
||||
{branchFilter ? 'No matching branches' : 'No branches found'}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
filteredBranches.map((branch) => (
|
||||
<DropdownMenuItem
|
||||
key={branch.name}
|
||||
onClick={() => onSwitchBranch(worktree, branch.name)}
|
||||
disabled={isSwitching || branch.name === worktree.branch}
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{branch.name === worktree.branch ? (
|
||||
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
||||
) : (
|
||||
<span className="w-3.5 mr-2 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
<>
|
||||
{/* Local branches */}
|
||||
{localBranches.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-[10px] text-muted-foreground uppercase tracking-wider px-2 py-1">
|
||||
Local
|
||||
</DropdownMenuLabel>
|
||||
{localBranches.map(renderBranchItem)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Remote branches */}
|
||||
{remoteBranches.length > 0 && (
|
||||
<>
|
||||
{localBranches.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel className="text-[10px] text-muted-foreground uppercase tracking-wider px-2 py-1">
|
||||
Remote
|
||||
</DropdownMenuLabel>
|
||||
{remoteBranches.map(renderBranchItem)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -17,7 +17,7 @@ export function useBranches() {
|
||||
data: branchData,
|
||||
isLoading: isLoadingBranches,
|
||||
refetch,
|
||||
} = useWorktreeBranches(currentWorktreePath);
|
||||
} = useWorktreeBranches(currentWorktreePath, true);
|
||||
|
||||
const branches = branchData?.branches ?? [];
|
||||
const aheadCount = branchData?.aheadCount ?? 0;
|
||||
|
||||
@@ -13,12 +13,23 @@ import type { WorktreeInfo } from '../types';
|
||||
|
||||
const logger = createLogger('WorktreeActions');
|
||||
|
||||
export function useWorktreeActions() {
|
||||
interface UseWorktreeActionsOptions {
|
||||
/** Callback when merge conflicts occur after branch switch stash reapply */
|
||||
onBranchSwitchConflict?: (info: {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
previousBranch: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
||||
const navigate = useNavigate();
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
|
||||
// Use React Query mutations
|
||||
const switchBranchMutation = useSwitchBranch();
|
||||
const switchBranchMutation = useSwitchBranch({
|
||||
onConflict: options?.onBranchSwitchConflict,
|
||||
});
|
||||
const pullMutation = usePullWorktree();
|
||||
const pushMutation = usePushWorktree();
|
||||
const openInEditorMutation = useOpenInEditor();
|
||||
|
||||
@@ -80,6 +80,12 @@ export interface MergeConflictInfo {
|
||||
targetWorktreePath: string;
|
||||
}
|
||||
|
||||
export interface BranchSwitchConflictInfo {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
previousBranch: string;
|
||||
}
|
||||
|
||||
export interface WorktreePanelProps {
|
||||
projectPath: string;
|
||||
onCreateWorktree: () => void;
|
||||
@@ -90,6 +96,8 @@ export interface WorktreePanelProps {
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
/** Called when branch switch stash reapply results in merge conflicts */
|
||||
onBranchSwitchConflict?: (conflictInfo: BranchSwitchConflictInfo) => void;
|
||||
/** Called when a branch is deleted during merge - features should be reassigned to main */
|
||||
onBranchDeletedDuringMerge?: (branchName: string) => void;
|
||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||
|
||||
@@ -14,7 +14,12 @@ import type {
|
||||
TestRunnerOutputEvent,
|
||||
TestRunnerCompletedEvent,
|
||||
} from '@/types/electron';
|
||||
import type { WorktreePanelProps, WorktreeInfo, TestSessionInfo } from './types';
|
||||
import type {
|
||||
WorktreePanelProps,
|
||||
WorktreeInfo,
|
||||
TestSessionInfo,
|
||||
BranchSwitchConflictInfo,
|
||||
} from './types';
|
||||
import {
|
||||
useWorktrees,
|
||||
useDevServers,
|
||||
@@ -50,6 +55,7 @@ export function WorktreePanel({
|
||||
onAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onCreateMergeConflictResolutionFeature,
|
||||
onBranchSwitchConflict,
|
||||
onBranchDeletedDuringMerge,
|
||||
onRemovedWorktrees,
|
||||
runningFeatureIds = [],
|
||||
@@ -101,7 +107,9 @@ export function WorktreePanel({
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleOpenInEditor,
|
||||
handleOpenInExternalTerminal,
|
||||
} = useWorktreeActions();
|
||||
} = useWorktreeActions({
|
||||
onBranchSwitchConflict: onBranchSwitchConflict,
|
||||
});
|
||||
|
||||
const { hasRunningFeatures } = useRunningFeatures({
|
||||
runningFeatureIds,
|
||||
|
||||
@@ -489,7 +489,7 @@ export function DashboardView() {
|
||||
const hasProjects = projects.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-screen content-bg" data-testid="dashboard-view">
|
||||
<div className="flex-1 flex flex-col h-full content-bg" data-testid="dashboard-view">
|
||||
{/* Header with logo */}
|
||||
<header className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
||||
{/* Electron titlebar drag region */}
|
||||
|
||||
@@ -6,7 +6,7 @@ export function LoggedOutView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-full items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
|
||||
@@ -348,7 +348,7 @@ export function LoginView() {
|
||||
// Checking server connectivity
|
||||
if (state.phase === 'checking_server') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-full items-center justify-center bg-background p-4">
|
||||
<div className="text-center space-y-4">
|
||||
<Spinner size="xl" className="mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -363,7 +363,7 @@ export function LoginView() {
|
||||
// Server unreachable after retries
|
||||
if (state.phase === 'server_error') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-full items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md space-y-6 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<ServerCrash className="h-8 w-8 text-destructive" />
|
||||
@@ -384,7 +384,7 @@ export function LoginView() {
|
||||
// Checking setup status after auth
|
||||
if (state.phase === 'checking_setup' || state.phase === 'redirecting') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-full items-center justify-center bg-background p-4">
|
||||
<div className="text-center space-y-4">
|
||||
<Spinner size="xl" className="mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -401,7 +401,7 @@ export function LoginView() {
|
||||
const error = state.phase === 'awaiting_login' ? state.error : null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-full items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
|
||||
@@ -243,7 +243,7 @@ export function OverviewView() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-screen content-bg" data-testid="overview-view">
|
||||
<div className="flex-1 flex flex-col h-full content-bg" data-testid="overview-view">
|
||||
{/* Header */}
|
||||
<header className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
||||
{/* Electron titlebar drag region */}
|
||||
|
||||
@@ -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