mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
Comprehensive set of mobile and all improvements phase 1
This commit is contained in:
@@ -137,7 +137,7 @@ export function BoardHeader({
|
||||
}, [isRefreshingBoard, onRefreshBoard]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center justify-between gap-5 px-4 py-2 sm:p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<BoardSearchBar
|
||||
searchQuery={searchQuery}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { useAppStore, formatShortcut } from '@/store/app-store';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
|
||||
import { ListHeader } from './list-header';
|
||||
@@ -134,7 +135,7 @@ const EmptyState = memo(function EmptyState({ onAddFeature }: { onAddFeature?: (
|
||||
>
|
||||
<p className="text-sm mb-4">No features to display</p>
|
||||
{onAddFeature && (
|
||||
<Button variant="outline" size="sm" onClick={onAddFeature}>
|
||||
<Button variant="default" size="sm" onClick={onAddFeature}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
</Button>
|
||||
@@ -197,6 +198,10 @@ export const ListView = memo(function ListView({
|
||||
// Track collapsed state for each status group
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// Get the keyboard shortcut for adding features
|
||||
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
|
||||
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
|
||||
|
||||
// Generate status groups from columnFeaturesMap
|
||||
const statusGroups = useMemo<StatusGroup[]>(() => {
|
||||
const columns = getColumnsWithPipeline(pipelineConfig);
|
||||
@@ -439,18 +444,21 @@ export const ListView = memo(function ListView({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer with Add Feature button */}
|
||||
{/* Footer with Add Feature button, styled like board view */}
|
||||
{onAddFeature && (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<div className="border-t border-border px-4 py-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onAddFeature}
|
||||
className="w-full sm:w-auto"
|
||||
className="w-full h-9 text-sm"
|
||||
data-testid="list-view-add-feature"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
|
||||
{formatShortcut(addFeatureShortcut, true)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Undo2,
|
||||
FilePlus,
|
||||
FileX,
|
||||
FilePen,
|
||||
FileText,
|
||||
File,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface DiscardWorktreeChangesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onDiscarded: () => 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 DiscardWorktreeChangesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onDiscarded,
|
||||
}: DiscardWorktreeChangesDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 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);
|
||||
setError(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 discard 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 handleDiscard = async () => {
|
||||
if (!worktree || selectedFiles.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
|
||||
// Pass selected files if not all files are selected
|
||||
const filesToDiscard =
|
||||
selectedFiles.size === files.length ? undefined : Array.from(selectedFiles);
|
||||
|
||||
const result = await api.worktree.discardChanges(worktree.path, filesToDiscard);
|
||||
|
||||
if (result.success && result.result) {
|
||||
if (result.result.discarded) {
|
||||
const fileCount = filesToDiscard ? filesToDiscard.length : result.result.filesDiscarded;
|
||||
toast.success('Changes discarded', {
|
||||
description: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'} in ${worktree.branch}`,
|
||||
});
|
||||
onDiscarded();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.info('No changes to discard', {
|
||||
description: result.result.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to discard changes');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to discard changes');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
const allSelected = selectedFiles.size === files.length && files.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Undo2 className="w-5 h-5 text-destructive" />
|
||||
Discard Changes
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select which changes to discard in the{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree.
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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 discard
|
||||
{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>
|
||||
|
||||
{/* Warning message */}
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-destructive/10 border border-destructive/20">
|
||||
<AlertTriangle className="w-4 h-4 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-destructive">
|
||||
This will permanently discard the selected changes. Staged changes will be unstaged,
|
||||
modifications to tracked files will be reverted, and untracked files will be deleted.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDiscard}
|
||||
disabled={isLoading || selectedFiles.size === 0}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Discarding...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Undo2 className="w-4 h-4 mr-2" />
|
||||
Discard
|
||||
{selectedFiles.size > 0 && selectedFiles.size < files.length
|
||||
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
|
||||
: selectedFiles.size > 0
|
||||
? ` All (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
|
||||
: ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export { CompletedFeaturesModal } from './completed-features-modal';
|
||||
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
|
||||
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
|
||||
export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog';
|
||||
export { DiscardWorktreeChangesDialog } from './discard-worktree-changes-dialog';
|
||||
export { EditFeatureDialog } from './edit-feature-dialog';
|
||||
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
|
||||
export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { ReasoningEffort } from '@automaker/types';
|
||||
import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/description-image-dropzone';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { isConnectionError, handleServerOffline } from '@/lib/http-api-client';
|
||||
import { isConnectionError, handleServerOffline, getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||
import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
|
||||
@@ -903,17 +903,40 @@ export function useBoardActions({
|
||||
|
||||
const handleUnarchiveFeature = useCallback(
|
||||
(feature: Feature) => {
|
||||
const updates = {
|
||||
// Determine the branch to restore to:
|
||||
// - If the feature had a branch assigned, keep it (preserves worktree context)
|
||||
// - If no branch was assigned, it will show on the primary worktree
|
||||
const featureBranch = feature.branchName;
|
||||
|
||||
// Check if the feature will be visible on the current worktree view
|
||||
const willBeVisibleOnCurrentView = !featureBranch
|
||||
? !currentWorktreeBranch ||
|
||||
(projectPath ? isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch) : true)
|
||||
: featureBranch === currentWorktreeBranch;
|
||||
|
||||
const updates: Partial<Feature> = {
|
||||
status: 'verified' as const,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
|
||||
toast.success('Feature restored', {
|
||||
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
if (willBeVisibleOnCurrentView) {
|
||||
toast.success('Feature restored', {
|
||||
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
} else {
|
||||
toast.success('Feature restored', {
|
||||
description: `Moved back to verified on branch "${featureBranch}": ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
[
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
currentWorktreeBranch,
|
||||
projectPath,
|
||||
isPrimaryWorktreeBranch,
|
||||
]
|
||||
);
|
||||
|
||||
const handleViewOutput = useCallback(
|
||||
@@ -1073,28 +1096,53 @@ export function useBoardActions({
|
||||
|
||||
const handleArchiveAllVerified = useCallback(async () => {
|
||||
const verifiedFeatures = features.filter((f) => f.status === 'verified');
|
||||
if (verifiedFeatures.length === 0) return;
|
||||
|
||||
// Optimistically update all features in the UI immediately
|
||||
for (const feature of verifiedFeatures) {
|
||||
const isRunning = runningAutoTasks.includes(feature.id);
|
||||
if (isRunning) {
|
||||
try {
|
||||
await autoMode.stopFeature(feature.id);
|
||||
} catch (error) {
|
||||
logger.error('Error stopping feature before archive:', error);
|
||||
updateFeature(feature.id, { status: 'completed' as const });
|
||||
}
|
||||
|
||||
// Stop any running features in parallel (non-blocking for the UI)
|
||||
const runningVerified = verifiedFeatures.filter((f) => runningAutoTasks.includes(f.id));
|
||||
if (runningVerified.length > 0) {
|
||||
await Promise.allSettled(
|
||||
runningVerified.map((feature) =>
|
||||
autoMode.stopFeature(feature.id).catch((error) => {
|
||||
logger.error('Error stopping feature before archive:', error);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Use bulk update API for a single server request instead of N individual calls
|
||||
try {
|
||||
if (currentProject) {
|
||||
const api = getHttpApiClient();
|
||||
const featureIds = verifiedFeatures.map((f) => f.id);
|
||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, {
|
||||
status: 'completed' as const,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Refresh features from server to sync React Query cache
|
||||
loadFeatures();
|
||||
} else {
|
||||
logger.error('Bulk archive failed:', result);
|
||||
// Reload features to sync state with server
|
||||
loadFeatures();
|
||||
}
|
||||
}
|
||||
// Archive the feature by setting status to completed
|
||||
const updates = {
|
||||
status: 'completed' as const,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
} catch (error) {
|
||||
logger.error('Failed to bulk archive features:', error);
|
||||
// Reload features to sync state with server on error
|
||||
loadFeatures();
|
||||
}
|
||||
|
||||
toast.success('All verified features archived', {
|
||||
description: `Archived ${verifiedFeatures.length} feature(s).`,
|
||||
});
|
||||
}, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]);
|
||||
}, [features, runningAutoTasks, autoMode, updateFeature, currentProject, loadFeatures]);
|
||||
|
||||
const handleDuplicateFeature = useCallback(
|
||||
async (feature: Feature, asChild: boolean = false) => {
|
||||
|
||||
@@ -28,10 +28,29 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
// Capture previous cache snapshot for rollback on error
|
||||
const previousFeatures = queryClient.getQueryData<Feature[]>(
|
||||
queryKeys.features.all(currentProject.path)
|
||||
);
|
||||
|
||||
// Optimistically update React Query cache for immediate board refresh
|
||||
// This ensures status changes (e.g., restoring archived features) are reflected immediately
|
||||
queryClient.setQueryData<Feature[]>(
|
||||
queryKeys.features.all(currentProject.path),
|
||||
(existing) => {
|
||||
if (!existing) return existing;
|
||||
return existing.map((f) => (f.id === featureId ? { ...f, ...updates } : f));
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
logger.error('Features API not available');
|
||||
// Rollback optimistic update since we can't persist
|
||||
if (previousFeatures) {
|
||||
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,6 +70,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
if (result.success && result.feature) {
|
||||
const updatedFeature = result.feature as Feature;
|
||||
updateFeature(updatedFeature.id, updatedFeature as Partial<Feature>);
|
||||
// Update cache with server-confirmed feature before invalidating
|
||||
queryClient.setQueryData<Feature[]>(
|
||||
queryKeys.features.all(currentProject.path),
|
||||
(features) => {
|
||||
@@ -66,9 +86,23 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
});
|
||||
} else if (!result.success) {
|
||||
logger.error('API features.update failed', result);
|
||||
// Rollback optimistic update on failure
|
||||
if (previousFeatures) {
|
||||
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist feature update:', error);
|
||||
// Rollback optimistic update on error
|
||||
if (previousFeatures) {
|
||||
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentProject, updateFeature, queryClient]
|
||||
|
||||
@@ -318,7 +318,7 @@ export function KanbanBoard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 overflow-x-auto px-5 pt-4 pb-4 relative',
|
||||
'flex-1 overflow-x-auto px-5 pt-2 sm:pt-4 pb-1 sm:pb-4 relative',
|
||||
'transition-opacity duration-200',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -14,12 +14,7 @@ import type {
|
||||
TestRunnerOutputEvent,
|
||||
TestRunnerCompletedEvent,
|
||||
} from '@/types/electron';
|
||||
import type {
|
||||
WorktreePanelProps,
|
||||
WorktreeInfo,
|
||||
TestSessionInfo,
|
||||
BranchSwitchConflictInfo,
|
||||
} from './types';
|
||||
import type { WorktreePanelProps, WorktreeInfo, TestSessionInfo } from './types';
|
||||
import {
|
||||
useWorktrees,
|
||||
useDevServers,
|
||||
@@ -36,10 +31,13 @@ import {
|
||||
WorktreeDropdown,
|
||||
} from './components';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
ViewWorktreeChangesDialog,
|
||||
PushToRemoteDialog,
|
||||
MergeWorktreeDialog,
|
||||
DiscardWorktreeChangesDialog,
|
||||
} from '../dialogs';
|
||||
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
|
||||
@@ -471,30 +469,9 @@ export function WorktreePanel({
|
||||
setDiscardChangesDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDiscardChanges = useCallback(async () => {
|
||||
if (!discardChangesWorktree) return;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.discardChanges(discardChangesWorktree.path);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Changes discarded', {
|
||||
description: `Discarded changes in ${discardChangesWorktree.branch}`,
|
||||
});
|
||||
// Refresh worktrees to update the changes status
|
||||
fetchWorktrees({ silent: true });
|
||||
} else {
|
||||
toast.error('Failed to discard changes', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to discard changes', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}, [discardChangesWorktree, fetchWorktrees]);
|
||||
const handleDiscardCompleted = useCallback(() => {
|
||||
fetchWorktrees({ silent: true });
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
// Handle opening the log panel for a specific worktree
|
||||
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
|
||||
@@ -679,17 +656,12 @@ export function WorktreePanel({
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
|
||||
{/* Discard Changes Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
{/* Discard Changes Dialog */}
|
||||
<DiscardWorktreeChangesDialog
|
||||
open={discardChangesDialogOpen}
|
||||
onOpenChange={setDiscardChangesDialogOpen}
|
||||
onConfirm={handleConfirmDiscardChanges}
|
||||
title="Discard Changes"
|
||||
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
|
||||
icon={Undo2}
|
||||
iconClassName="text-destructive"
|
||||
confirmText="Discard Changes"
|
||||
confirmVariant="destructive"
|
||||
worktree={discardChangesWorktree}
|
||||
onDiscarded={handleDiscardCompleted}
|
||||
/>
|
||||
|
||||
{/* Dev Server Logs Panel */}
|
||||
@@ -1015,17 +987,12 @@ export function WorktreePanel({
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
|
||||
{/* Discard Changes Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
{/* Discard Changes Dialog */}
|
||||
<DiscardWorktreeChangesDialog
|
||||
open={discardChangesDialogOpen}
|
||||
onOpenChange={setDiscardChangesDialogOpen}
|
||||
onConfirm={handleConfirmDiscardChanges}
|
||||
title="Discard Changes"
|
||||
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
|
||||
icon={Undo2}
|
||||
iconClassName="text-destructive"
|
||||
confirmText="Discard Changes"
|
||||
confirmVariant="destructive"
|
||||
worktree={discardChangesWorktree}
|
||||
onDiscarded={handleDiscardCompleted}
|
||||
/>
|
||||
|
||||
{/* Dev Server Logs Panel */}
|
||||
|
||||
@@ -21,8 +21,6 @@ const SPECIAL_KEYS = {
|
||||
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;
|
||||
@@ -34,7 +32,7 @@ const ARROW_KEYS = {
|
||||
left: '\x1b[D',
|
||||
} as const;
|
||||
|
||||
interface MobileTerminalControlsProps {
|
||||
interface MobileTerminalShortcutsProps {
|
||||
/** Callback to send input data to the terminal WebSocket */
|
||||
onSendInput: (data: string) => void;
|
||||
/** Whether the terminal is connected and ready */
|
||||
@@ -42,14 +40,17 @@ interface MobileTerminalControlsProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile quick controls bar for terminal interaction on touch devices.
|
||||
* Mobile shortcuts 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) {
|
||||
export function MobileTerminalShortcuts({
|
||||
onSendInput,
|
||||
isConnected,
|
||||
}: MobileTerminalShortcutsProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
// Track repeat interval for arrow key long-press
|
||||
@@ -108,10 +109,10 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
|
||||
<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"
|
||||
title="Show shortcuts"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
<span>Controls</span>
|
||||
<span>Shortcuts</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -123,7 +124,7 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
|
||||
<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"
|
||||
title="Hide shortcuts"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -132,12 +133,12 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Special keys */}
|
||||
<ControlButton
|
||||
<ShortcutButton
|
||||
label="Esc"
|
||||
onPress={() => sendKey(SPECIAL_KEYS.escape)}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ControlButton
|
||||
<ShortcutButton
|
||||
label="Tab"
|
||||
onPress={() => sendKey(SPECIAL_KEYS.tab)}
|
||||
disabled={!isConnected}
|
||||
@@ -147,31 +148,19 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Common Ctrl shortcuts */}
|
||||
<ControlButton
|
||||
<ShortcutButton
|
||||
label="^C"
|
||||
title="Ctrl+C (Interrupt)"
|
||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+C'])}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ControlButton
|
||||
<ShortcutButton
|
||||
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
|
||||
<ShortcutButton
|
||||
label="^B"
|
||||
title="Ctrl+B (Back/tmux prefix)"
|
||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+B'])}
|
||||
@@ -181,26 +170,6 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
|
||||
{/* 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"
|
||||
@@ -226,14 +195,34 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
|
||||
onRelease={handleArrowRelease}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Navigation keys */}
|
||||
<ShortcutButton
|
||||
label="Del"
|
||||
onPress={() => sendKey(SPECIAL_KEYS.delete)}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ShortcutButton
|
||||
label="Home"
|
||||
onPress={() => sendKey(SPECIAL_KEYS.home)}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ShortcutButton
|
||||
label="End"
|
||||
onPress={() => sendKey(SPECIAL_KEYS.end)}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual control button for special keys and shortcuts.
|
||||
* Individual shortcut button for special keys.
|
||||
*/
|
||||
function ControlButton({
|
||||
function ShortcutButton({
|
||||
label,
|
||||
title,
|
||||
onPress,
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type StickyModifier = 'ctrl' | 'alt' | null;
|
||||
|
||||
interface StickyModifierKeysProps {
|
||||
/** Currently active sticky modifier (null = none) */
|
||||
activeModifier: StickyModifier;
|
||||
/** Callback when a modifier is toggled */
|
||||
onModifierChange: (modifier: StickyModifier) => void;
|
||||
/** Whether the terminal is connected */
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky modifier keys (Ctrl, Alt) for the terminal toolbar.
|
||||
*
|
||||
* "Sticky" means: tap a modifier to activate it, then the next key pressed
|
||||
* in the terminal will be sent with that modifier applied. After the modified
|
||||
* key is sent, the sticky modifier automatically deactivates.
|
||||
*
|
||||
* - Ctrl: Sends the control code (character code & 0x1f)
|
||||
* - Alt: Sends escape prefix (\x1b) before the character
|
||||
*
|
||||
* Tapping an already-active modifier deactivates it (toggle behavior).
|
||||
*/
|
||||
export function StickyModifierKeys({
|
||||
activeModifier,
|
||||
onModifierChange,
|
||||
isConnected,
|
||||
}: StickyModifierKeysProps) {
|
||||
const toggleCtrl = useCallback(() => {
|
||||
onModifierChange(activeModifier === 'ctrl' ? null : 'ctrl');
|
||||
}, [activeModifier, onModifierChange]);
|
||||
|
||||
const toggleAlt = useCallback(() => {
|
||||
onModifierChange(activeModifier === 'alt' ? null : 'alt');
|
||||
}, [activeModifier, onModifierChange]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<ModifierButton
|
||||
label="Ctrl"
|
||||
isActive={activeModifier === 'ctrl'}
|
||||
onPress={toggleCtrl}
|
||||
disabled={!isConnected}
|
||||
title="Sticky Ctrl – tap to activate, then press a key (e.g. Ctrl+C)"
|
||||
/>
|
||||
<ModifierButton
|
||||
label="Alt"
|
||||
isActive={activeModifier === 'alt'}
|
||||
onPress={toggleAlt}
|
||||
disabled={!isConnected}
|
||||
title="Sticky Alt – tap to activate, then press a key (e.g. Alt+D)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual modifier toggle button with active state styling.
|
||||
*/
|
||||
function ModifierButton({
|
||||
label,
|
||||
isActive,
|
||||
onPress,
|
||||
disabled = false,
|
||||
title,
|
||||
}: {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md text-xs font-medium shrink-0 select-none transition-all min-w-[36px] min-h-[28px] flex items-center justify-center',
|
||||
'touch-manipulation border',
|
||||
isActive
|
||||
? 'bg-brand-500 text-white border-brand-500 shadow-sm shadow-brand-500/25'
|
||||
: 'bg-muted/80 text-foreground hover:bg-accent border-transparent',
|
||||
disabled && 'opacity-40 pointer-events-none'
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault(); // Prevent focus stealing from terminal
|
||||
onPress();
|
||||
}}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
aria-pressed={isActive}
|
||||
role="switch"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a sticky modifier to raw terminal input data.
|
||||
*
|
||||
* For Ctrl: converts printable ASCII characters to their control-code equivalent.
|
||||
* e.g. 'c' → \x03 (Ctrl+C), 'a' → \x01 (Ctrl+A)
|
||||
*
|
||||
* For Alt: prepends the escape character (\x1b) before the data.
|
||||
* e.g. 'd' → \x1bd (Alt+D)
|
||||
*
|
||||
* Returns null if the modifier cannot be applied (non-ASCII, etc.)
|
||||
*/
|
||||
export function applyStickyModifier(data: string, modifier: StickyModifier): string | null {
|
||||
if (!modifier || !data) return null;
|
||||
|
||||
if (modifier === 'ctrl') {
|
||||
// Only apply Ctrl to single printable ASCII characters (a-z, A-Z, and some specials)
|
||||
if (data.length === 1) {
|
||||
const code = data.charCodeAt(0);
|
||||
|
||||
// Letters a-z or A-Z: Ctrl sends code & 0x1f
|
||||
if ((code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a)) {
|
||||
return String.fromCharCode(code & 0x1f);
|
||||
}
|
||||
|
||||
// Special Ctrl combinations
|
||||
// Ctrl+[ = Escape (0x1b)
|
||||
if (code === 0x5b) return '\x1b';
|
||||
// Ctrl+\ = 0x1c
|
||||
if (code === 0x5c) return '\x1c';
|
||||
// Ctrl+] = 0x1d
|
||||
if (code === 0x5d) return '\x1d';
|
||||
// Ctrl+^ = 0x1e
|
||||
if (code === 0x5e) return '\x1e';
|
||||
// Ctrl+_ = 0x1f
|
||||
if (code === 0x5f) return '\x1f';
|
||||
// Ctrl+Space or Ctrl+@ = 0x00 (NUL)
|
||||
if (code === 0x20 || code === 0x40) return '\x00';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (modifier === 'alt') {
|
||||
// Alt sends ESC prefix followed by the character
|
||||
return '\x1b' + data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -53,7 +53,12 @@ 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';
|
||||
import { MobileTerminalShortcuts } from './mobile-terminal-shortcuts';
|
||||
import {
|
||||
StickyModifierKeys,
|
||||
applyStickyModifier,
|
||||
type StickyModifier,
|
||||
} from './sticky-modifier-keys';
|
||||
|
||||
const logger = createLogger('Terminal');
|
||||
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||
@@ -158,6 +163,10 @@ export function TerminalPanel({
|
||||
const showSearchRef = useRef(false);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
|
||||
// Sticky modifier key state (Ctrl or Alt) for the terminal toolbar
|
||||
const [stickyModifier, setStickyModifier] = useState<StickyModifier>(null);
|
||||
const stickyModifierRef = useRef<StickyModifier>(null);
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'auth_failed'
|
||||
>('connecting');
|
||||
@@ -166,7 +175,7 @@ export function TerminalPanel({
|
||||
const INITIAL_RECONNECT_DELAY = 1000;
|
||||
const [processExitCode, setProcessExitCode] = useState<number | null>(null);
|
||||
|
||||
// Detect mobile viewport for quick controls
|
||||
// Detect mobile viewport for shortcuts bar
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Track virtual keyboard height on mobile to prevent overlap
|
||||
@@ -354,7 +363,13 @@ export function TerminalPanel({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Send raw input to terminal via WebSocket (used by mobile quick controls)
|
||||
// Handle sticky modifier toggle and keep ref in sync
|
||||
const handleStickyModifierChange = useCallback((modifier: StickyModifier) => {
|
||||
setStickyModifier(modifier);
|
||||
stickyModifierRef.current = modifier;
|
||||
}, []);
|
||||
|
||||
// Send raw input to terminal via WebSocket (used by mobile shortcuts bar)
|
||||
const sendTerminalInput = useCallback((data: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'input', data }));
|
||||
@@ -1207,10 +1222,24 @@ export function TerminalPanel({
|
||||
|
||||
connect();
|
||||
|
||||
// Handle terminal input
|
||||
// Handle terminal input - apply sticky modifier if active
|
||||
const dataHandler = terminal.onData((data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'input', data }));
|
||||
const modifier = stickyModifierRef.current;
|
||||
if (modifier) {
|
||||
const modified = applyStickyModifier(data, modifier);
|
||||
if (modified !== null) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'input', data: modified }));
|
||||
} else {
|
||||
// Could not apply modifier (e.g. non-ASCII input), send as-is
|
||||
wsRef.current.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
// Clear sticky modifier after one key press (one-shot behavior)
|
||||
stickyModifierRef.current = null;
|
||||
setStickyModifier(null);
|
||||
} else {
|
||||
wsRef.current.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2037,6 +2066,15 @@ 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"
|
||||
@@ -2157,9 +2195,9 @@ export function TerminalPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile quick controls - special keys and arrow keys for touch devices */}
|
||||
{/* Mobile shortcuts bar - special keys and arrow keys for touch devices */}
|
||||
{isMobile && (
|
||||
<MobileTerminalControls
|
||||
<MobileTerminalShortcuts
|
||||
onSendInput={sendTerminalInput}
|
||||
isConnected={connectionStatus === 'connected'}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user