mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
Improve pull request flow, add branch selection for worktree creation, fix auto-mode concurrency count (#787)
* Changes from fix/fetch-before-pull-fetch * feat: Improve pull request flow, add branch selection for worktree creation, fix for automode concurrency count * feat: Add validation for remote names and improve error handling * Address PR comments and mobile layout fixes * ``` refactor: Extract PR target resolution logic into dedicated service ``` * feat: Add app shell UI and improve service imports. Address PR comments * fix: Improve security validation and cache handling in git operations * feat: Add GET /list endpoint and improve parameter handling * chore: Improve validation, accessibility, and error handling across apps * chore: Format vite server port configuration * fix: Add error handling for gh pr list command and improve offline fallbacks * fix: Preserve existing PR creation time and improve remote handling
This commit is contained in:
@@ -49,6 +49,8 @@ export function AgentView() {
|
||||
|
||||
// Ref for quick create session function from SessionManager
|
||||
const quickCreateSessionRef = useRef<(() => Promise<void>) | null>(null);
|
||||
// Guard to prevent concurrent invocations of handleCreateSessionFromEmptyState
|
||||
const createSessionInFlightRef = useRef(false);
|
||||
|
||||
// Session management hook
|
||||
const { currentSessionId, handleSelectSession } = useAgentSession({
|
||||
@@ -130,6 +132,51 @@ export function AgentView() {
|
||||
await clearHistory();
|
||||
};
|
||||
|
||||
// Handle creating a new session from empty state.
|
||||
// On mobile the SessionManager may be unmounted (hidden), clearing the ref.
|
||||
// In that case, show it first and wait for the component to mount and
|
||||
// re-populate quickCreateSessionRef before invoking it.
|
||||
//
|
||||
// A single requestAnimationFrame isn't always sufficient — React concurrent
|
||||
// mode or slow devices may not have committed the SessionManager mount by
|
||||
// the next frame. We use a double-RAF with a short retry loop to wait more
|
||||
// robustly for the ref to be populated.
|
||||
const handleCreateSessionFromEmptyState = useCallback(async () => {
|
||||
if (createSessionInFlightRef.current) return;
|
||||
createSessionInFlightRef.current = true;
|
||||
try {
|
||||
let createFn = quickCreateSessionRef.current;
|
||||
if (!createFn) {
|
||||
// SessionManager is likely unmounted on mobile — show it so it mounts
|
||||
setShowSessionManager(true);
|
||||
// Wait for mount: double RAF + retry loop (handles concurrent mode & slow devices)
|
||||
const MAX_RETRIES = 5;
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
await new Promise<void>((r) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => r()))
|
||||
);
|
||||
createFn = quickCreateSessionRef.current;
|
||||
if (createFn) break;
|
||||
// Small delay between retries to give React time to commit
|
||||
if (i < MAX_RETRIES - 1) {
|
||||
await new Promise<void>((r) => setTimeout(r, 50));
|
||||
createFn = quickCreateSessionRef.current;
|
||||
if (createFn) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (createFn) {
|
||||
await createFn();
|
||||
} else {
|
||||
console.warn(
|
||||
'[AgentView] quickCreateSessionRef was not populated after retries — SessionManager may not have mounted'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
createSessionInFlightRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-focus input when session is selected/changed
|
||||
useEffect(() => {
|
||||
if (currentSessionId && inputRef.current) {
|
||||
@@ -177,7 +224,7 @@ export function AgentView() {
|
||||
|
||||
{/* Session Manager Sidebar */}
|
||||
{showSessionManager && currentProject && (
|
||||
<div className="fixed inset-y-0 left-0 w-72 z-30 lg:relative lg:w-80 lg:z-auto border-r border-border shrink-0 bg-card">
|
||||
<div className="fixed inset-y-0 left-0 w-72 z-30 pt-[env(safe-area-inset-top,0px)] lg:pt-0 lg:relative lg:w-80 lg:z-auto border-r border-border shrink-0 bg-card">
|
||||
<SessionManager
|
||||
currentSessionId={currentSessionId}
|
||||
onSelectSession={handleSelectSession}
|
||||
@@ -212,6 +259,7 @@ export function AgentView() {
|
||||
messagesContainerRef={messagesContainerRef}
|
||||
onScroll={handleScroll}
|
||||
onShowSessionManager={() => setShowSessionManager(true)}
|
||||
onCreateSession={handleCreateSessionFromEmptyState}
|
||||
/>
|
||||
|
||||
{/* Input Area */}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface ChatAreaProps {
|
||||
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
onScroll: () => void;
|
||||
onShowSessionManager: () => void;
|
||||
onCreateSession?: () => void;
|
||||
}
|
||||
|
||||
export function ChatArea({
|
||||
@@ -29,12 +30,14 @@ export function ChatArea({
|
||||
messagesContainerRef,
|
||||
onScroll,
|
||||
onShowSessionManager,
|
||||
onCreateSession,
|
||||
}: ChatAreaProps) {
|
||||
if (!currentSessionId) {
|
||||
return (
|
||||
<NoSessionState
|
||||
showSessionManager={showSessionManager}
|
||||
onShowSessionManager={onShowSessionManager}
|
||||
onCreateSession={onCreateSession}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Sparkles, Bot, PanelLeft } from 'lucide-react';
|
||||
import { Sparkles, Bot, PanelLeft, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function NoProjectState() {
|
||||
@@ -23,9 +23,14 @@ export function NoProjectState() {
|
||||
interface NoSessionStateProps {
|
||||
showSessionManager: boolean;
|
||||
onShowSessionManager: () => void;
|
||||
onCreateSession?: () => void;
|
||||
}
|
||||
|
||||
export function NoSessionState({ showSessionManager, onShowSessionManager }: NoSessionStateProps) {
|
||||
export function NoSessionState({
|
||||
showSessionManager,
|
||||
onShowSessionManager,
|
||||
onCreateSession,
|
||||
}: NoSessionStateProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center bg-background"
|
||||
@@ -39,10 +44,23 @@ export function NoSessionState({ showSessionManager, onShowSessionManager }: NoS
|
||||
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
|
||||
Create or select a session to start chatting with the AI agent
|
||||
</p>
|
||||
<Button onClick={onShowSessionManager} variant="outline" className="gap-2">
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
{showSessionManager ? 'View' : 'Show'} Sessions
|
||||
</Button>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{onCreateSession && (
|
||||
<Button
|
||||
onClick={onCreateSession}
|
||||
variant="default"
|
||||
className="gap-2"
|
||||
data-testid="empty-state-new-session-button"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Session
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onShowSessionManager} variant="outline" className="gap-2">
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
{showSessionManager ? 'View' : 'Show'} Sessions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,6 @@ import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/ty
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
@@ -880,7 +879,8 @@ export function BoardView() {
|
||||
// Capture existing feature IDs before adding
|
||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||
try {
|
||||
await handleAddFeature(featureData);
|
||||
// Create feature directly with in_progress status to avoid brief backlog flash
|
||||
await handleAddFeature({ ...featureData, initialStatus: 'in_progress' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create feature:', error);
|
||||
toast.error('Failed to create feature', {
|
||||
@@ -894,7 +894,14 @@ export function BoardView() {
|
||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
||||
|
||||
if (newFeature) {
|
||||
await handleStartImplementation(newFeature);
|
||||
try {
|
||||
await handleStartImplementation(newFeature);
|
||||
} catch (startError) {
|
||||
logger.error('Failed to start implementation for feature:', startError);
|
||||
toast.error('Failed to start feature implementation', {
|
||||
description: startError instanceof Error ? startError.message : 'An error occurred',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.error('Could not find newly created feature to start it automatically.');
|
||||
toast.error('Failed to auto-start feature', {
|
||||
@@ -1225,6 +1232,7 @@ export function BoardView() {
|
||||
const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({
|
||||
features: hookFeatures,
|
||||
runningAutoTasks,
|
||||
runningAutoTasksAllWorktrees,
|
||||
searchQuery,
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch,
|
||||
@@ -1393,14 +1401,6 @@ export function BoardView() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg relative"
|
||||
|
||||
@@ -468,7 +468,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
{feature.titleGenerating ? (
|
||||
{feature.titleGenerating && !feature.title ? (
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Spinner size="xs" />
|
||||
<span className="text-xs text-muted-foreground italic">Generating title...</span>
|
||||
|
||||
@@ -96,8 +96,8 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
||||
// Smooth scrolling
|
||||
'scroll-smooth',
|
||||
// Add padding at bottom if there's a footer action
|
||||
footerAction && 'pb-14',
|
||||
// Add padding at bottom if there's a footer action (less on mobile to reduce blank space)
|
||||
footerAction && 'pb-12 sm:pb-14',
|
||||
contentClassName
|
||||
)}
|
||||
ref={contentRef}
|
||||
@@ -109,7 +109,7 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
|
||||
{/* Floating Footer Action */}
|
||||
{footerAction && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-20 p-2 bg-gradient-to-t from-card/95 via-card/80 to-transparent pt-6">
|
||||
<div className="absolute bottom-0 left-0 right-0 z-20 p-2 bg-gradient-to-t from-card/95 via-card/80 to-transparent pt-4 sm:pt-6">
|
||||
{footerAction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -297,7 +297,7 @@ export const ListRow = memo(function ListRow({
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium truncate',
|
||||
feature.titleGenerating && 'animate-pulse text-muted-foreground'
|
||||
feature.titleGenerating && !feature.title && 'animate-pulse text-muted-foreground'
|
||||
)}
|
||||
title={feature.title || feature.description}
|
||||
>
|
||||
|
||||
@@ -321,11 +321,11 @@ export function AgentOutputModal({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="w-full h-full max-w-full max-h-full sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col"
|
||||
className="w-full max-h-[85dvh] max-w-[calc(100%-2rem)] sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] rounded-xl flex flex-col"
|
||||
data-testid="agent-output-modal"
|
||||
>
|
||||
<DialogHeader className="shrink-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-10">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
||||
<Spinner size="md" />
|
||||
|
||||
@@ -493,7 +493,7 @@ export function CherryPickDialog({
|
||||
if (step === 'select-commits') {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] 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-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Cherry className="w-5 h-5 text-foreground" />
|
||||
|
||||
@@ -11,6 +11,13 @@ import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
GitCommit,
|
||||
Sparkles,
|
||||
@@ -21,6 +28,7 @@ import {
|
||||
File,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
@@ -31,6 +39,11 @@ import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
|
||||
|
||||
interface RemoteInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
@@ -178,6 +191,17 @@ export function CommitWorktreeDialog({
|
||||
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
||||
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
|
||||
|
||||
// Push after commit state
|
||||
const [pushAfterCommit, setPushAfterCommit] = useState(false);
|
||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [remotesFetched, setRemotesFetched] = useState(false);
|
||||
const [remotesFetchError, setRemotesFetchError] = useState<string | null>(null);
|
||||
// Track whether the commit already succeeded so retries can skip straight to push
|
||||
const [commitSucceeded, setCommitSucceeded] = useState(false);
|
||||
|
||||
// Parse diffs
|
||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||
|
||||
@@ -190,6 +214,58 @@ export function CommitWorktreeDialog({
|
||||
return map;
|
||||
}, [parsedDiffs]);
|
||||
|
||||
// Fetch remotes when push option is enabled
|
||||
const fetchRemotesForWorktree = useCallback(
|
||||
async (worktreePath: string, signal?: { cancelled: boolean }) => {
|
||||
setIsLoadingRemotes(true);
|
||||
setRemotesFetchError(null);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.worktree?.listRemotes) {
|
||||
const result = await api.worktree.listRemotes(worktreePath);
|
||||
if (signal?.cancelled) return;
|
||||
setRemotesFetched(true);
|
||||
if (result.success && result.result) {
|
||||
const remoteInfos = result.result.remotes.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
}));
|
||||
setRemotes(remoteInfos);
|
||||
// Auto-select 'origin' if available, otherwise first remote
|
||||
if (remoteInfos.length > 0) {
|
||||
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
|
||||
setSelectedRemote(defaultRemote.name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// API not available — mark fetch as complete with an error so the UI
|
||||
// shows feedback instead of remaining in an empty/loading state.
|
||||
setRemotesFetchError('Remote listing not available');
|
||||
setRemotesFetched(true);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.cancelled) return;
|
||||
// Don't mark as successfully fetched — show an error with retry instead
|
||||
setRemotesFetchError(err instanceof Error ? err.message : 'Failed to fetch remotes');
|
||||
console.warn('Failed to fetch remotes:', err);
|
||||
} finally {
|
||||
if (!signal?.cancelled) setIsLoadingRemotes(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pushAfterCommit && worktree && !remotesFetched && !remotesFetchError) {
|
||||
const signal = { cancelled: false };
|
||||
fetchRemotesForWorktree(worktree.path, signal);
|
||||
return () => {
|
||||
signal.cancelled = true;
|
||||
};
|
||||
}
|
||||
}, [pushAfterCommit, worktree, remotesFetched, remotesFetchError, fetchRemotesForWorktree]);
|
||||
|
||||
// Load diffs when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
@@ -198,6 +274,14 @@ export function CommitWorktreeDialog({
|
||||
setDiffContent('');
|
||||
setSelectedFiles(new Set());
|
||||
setExpandedFile(null);
|
||||
// Reset push state
|
||||
setPushAfterCommit(false);
|
||||
setRemotes([]);
|
||||
setSelectedRemote('');
|
||||
setIsPushing(false);
|
||||
setRemotesFetched(false);
|
||||
setRemotesFetchError(null);
|
||||
setCommitSucceeded(false);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -278,14 +362,64 @@ export function CommitWorktreeDialog({
|
||||
setExpandedFile((prev) => (prev === filePath ? null : filePath));
|
||||
}, []);
|
||||
|
||||
/** Shared push helper — returns true if the push succeeded */
|
||||
const performPush = async (
|
||||
api: ReturnType<typeof getElectronAPI>,
|
||||
worktreePath: string,
|
||||
remoteName: string
|
||||
): Promise<boolean> => {
|
||||
if (!api?.worktree?.push) {
|
||||
toast.error('Push API not available');
|
||||
return false;
|
||||
}
|
||||
setIsPushing(true);
|
||||
try {
|
||||
const pushResult = await api.worktree.push(worktreePath, false, remoteName);
|
||||
if (pushResult.success && pushResult.result) {
|
||||
toast.success('Pushed to remote', {
|
||||
description: pushResult.result.message,
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
toast.error(pushResult.error || 'Failed to push to remote');
|
||||
return false;
|
||||
}
|
||||
} catch (pushErr) {
|
||||
toast.error(pushErr instanceof Error ? pushErr.message : 'Failed to push to remote');
|
||||
return false;
|
||||
} finally {
|
||||
setIsPushing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!worktree || !message.trim() || selectedFiles.size === 0) return;
|
||||
if (!worktree) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
|
||||
// If commit already succeeded on a previous attempt, skip straight to push (or close if no push needed)
|
||||
if (commitSucceeded) {
|
||||
if (pushAfterCommit && selectedRemote) {
|
||||
const ok = await performPush(api, worktree.path, selectedRemote);
|
||||
if (ok) {
|
||||
onCommitted();
|
||||
onOpenChange(false);
|
||||
setMessage('');
|
||||
}
|
||||
} else {
|
||||
onCommitted();
|
||||
onOpenChange(false);
|
||||
setMessage('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.trim() || selectedFiles.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.commit) {
|
||||
setError('Worktree API not available');
|
||||
return;
|
||||
@@ -299,12 +433,27 @@ export function CommitWorktreeDialog({
|
||||
|
||||
if (result.success && result.result) {
|
||||
if (result.result.committed) {
|
||||
setCommitSucceeded(true);
|
||||
toast.success('Changes committed', {
|
||||
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
|
||||
});
|
||||
onCommitted();
|
||||
onOpenChange(false);
|
||||
setMessage('');
|
||||
|
||||
// Push after commit if enabled
|
||||
let pushSucceeded = false;
|
||||
if (pushAfterCommit && selectedRemote) {
|
||||
pushSucceeded = await performPush(api, worktree.path, selectedRemote);
|
||||
}
|
||||
|
||||
// Only close the dialog when no push was requested or the push completed successfully.
|
||||
// If push failed, keep the dialog open so the user can retry.
|
||||
if (!pushAfterCommit || pushSucceeded) {
|
||||
onCommitted();
|
||||
onOpenChange(false);
|
||||
setMessage('');
|
||||
} else {
|
||||
// Commit succeeded but push failed — notify parent of commit but keep dialog open for retry
|
||||
onCommitted();
|
||||
}
|
||||
} else {
|
||||
toast.info('No changes to commit', {
|
||||
description: result.result.message,
|
||||
@@ -320,16 +469,30 @@ export function CommitWorktreeDialog({
|
||||
}
|
||||
};
|
||||
|
||||
// When the commit succeeded but push failed, allow retrying the push without
|
||||
// requiring a commit message or file selection.
|
||||
const isPushRetry = commitSucceeded && pushAfterCommit && !isPushing;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
!isLoading &&
|
||||
!isGenerating &&
|
||||
message.trim() &&
|
||||
selectedFiles.size > 0
|
||||
!isPushing &&
|
||||
!isGenerating
|
||||
) {
|
||||
handleCommit();
|
||||
if (isPushRetry) {
|
||||
// Push retry only needs a selected remote
|
||||
if (selectedRemote) {
|
||||
handleCommit();
|
||||
}
|
||||
} else if (
|
||||
message.trim() &&
|
||||
selectedFiles.size > 0 &&
|
||||
!(pushAfterCommit && !selectedRemote)
|
||||
) {
|
||||
handleCommit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -390,8 +553,19 @@ export function CommitWorktreeDialog({
|
||||
|
||||
const allSelected = selectedFiles.size === files.length && files.length > 0;
|
||||
|
||||
// Prevent the dialog from being dismissed while a push is in progress.
|
||||
// Overlay clicks and Escape key both route through onOpenChange(false); we
|
||||
// intercept those here so the UI stays open until the push completes.
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen && isPushing) {
|
||||
// Ignore close requests during an active push.
|
||||
return;
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
@@ -580,9 +754,80 @@ export function CommitWorktreeDialog({
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* Push after commit option */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="push-after-commit"
|
||||
checked={pushAfterCommit}
|
||||
onCheckedChange={(checked) => setPushAfterCommit(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="push-after-commit"
|
||||
className="text-sm font-medium cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
Push to remote after commit
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{pushAfterCommit && (
|
||||
<div className="ml-6 flex flex-col gap-1.5">
|
||||
{isLoadingRemotes || (!remotesFetched && !remotesFetchError) ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner size="sm" />
|
||||
<span>Loading remotes...</span>
|
||||
</div>
|
||||
) : remotesFetchError ? (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<span>Failed to load remotes.</span>
|
||||
<button
|
||||
className="text-xs underline hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
if (worktree) {
|
||||
setRemotesFetchError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : remotes.length === 0 && remotesFetched ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No remotes configured for this repository.
|
||||
</p>
|
||||
) : remotes.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor="remote-select"
|
||||
className="text-xs text-muted-foreground whitespace-nowrap"
|
||||
>
|
||||
Remote:
|
||||
</Label>
|
||||
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
|
||||
<SelectTrigger id="remote-select" className="h-8 text-xs flex-1">
|
||||
<SelectValue placeholder="Select remote" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remotes.map((remote) => (
|
||||
<SelectItem key={remote.name} value={remote.name}>
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
<span className="ml-2 text-muted-foreground text-xs inline-block truncate max-w-[200px] align-bottom">
|
||||
{remote.url}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd/Ctrl+Enter</kbd> to
|
||||
commit
|
||||
commit{pushAfterCommit ? ' & push' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -590,23 +835,41 @@ export function CommitWorktreeDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading || isGenerating}
|
||||
disabled={isLoading || isPushing || isGenerating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCommit}
|
||||
disabled={isLoading || isGenerating || !message.trim() || selectedFiles.size === 0}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isPushing ||
|
||||
isGenerating ||
|
||||
(isPushRetry
|
||||
? !selectedRemote
|
||||
: !message.trim() ||
|
||||
selectedFiles.size === 0 ||
|
||||
(pushAfterCommit && !selectedRemote))
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
{isLoading || isPushing ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Committing...
|
||||
{isPushing ? 'Pushing...' : 'Committing...'}
|
||||
</>
|
||||
) : isPushRetry ? (
|
||||
<>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Retry Push
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitCommit className="w-4 h-4 mr-2" />
|
||||
Commit
|
||||
{pushAfterCommit ? (
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
) : (
|
||||
<GitCommit className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{pushAfterCommit ? 'Commit & Push' : 'Commit'}
|
||||
{selectedFiles.size > 0 && selectedFiles.size < files.length
|
||||
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
|
||||
: ''}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useWorktreeBranches } from '@/hooks/queries';
|
||||
interface RemoteInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
branches?: string[];
|
||||
}
|
||||
|
||||
interface WorktreeInfo {
|
||||
@@ -74,13 +75,19 @@ export function CreatePRDialog({
|
||||
// Remote selection state
|
||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||
// Target remote: which remote to create the PR against (may differ from push remote)
|
||||
const [selectedTargetRemote, setSelectedTargetRemote] = useState<string>('');
|
||||
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
|
||||
// Keep a ref in sync with selectedRemote so fetchRemotes can read the latest value
|
||||
// without needing it in its dependency array (which would cause re-fetch loops)
|
||||
const selectedRemoteRef = useRef<string>(selectedRemote);
|
||||
const selectedTargetRemoteRef = useRef<string>(selectedTargetRemote);
|
||||
useEffect(() => {
|
||||
selectedRemoteRef.current = selectedRemote;
|
||||
}, [selectedRemote]);
|
||||
useEffect(() => {
|
||||
selectedTargetRemoteRef.current = selectedTargetRemote;
|
||||
}, [selectedTargetRemote]);
|
||||
|
||||
// Generate description state
|
||||
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
|
||||
@@ -91,11 +98,52 @@ export function CreatePRDialog({
|
||||
true // Include remote branches for PR base branch selection
|
||||
);
|
||||
|
||||
// Determine if push remote selection is needed:
|
||||
// Show when there are unpushed commits, no remote tracking branch, or uncommitted changes
|
||||
// (uncommitted changes will be committed first, then pushed)
|
||||
const branchHasRemote = branchesData?.hasRemoteBranch ?? false;
|
||||
const branchAheadCount = branchesData?.aheadCount ?? 0;
|
||||
const needsPush = !branchHasRemote || branchAheadCount > 0 || !!worktree?.hasChanges;
|
||||
|
||||
// Filter out current worktree branch from the list
|
||||
// When a target remote is selected, only show branches from that remote
|
||||
const branches = useMemo(() => {
|
||||
if (!branchesData?.branches) return [];
|
||||
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
|
||||
}, [branchesData?.branches, worktree?.branch]);
|
||||
const allBranches = branchesData.branches
|
||||
.map((b) => b.name)
|
||||
.filter((name) => name !== worktree?.branch);
|
||||
|
||||
// If a target remote is selected and we have remote info with branches,
|
||||
// only show that remote's branches (not branches from other remotes)
|
||||
if (selectedTargetRemote) {
|
||||
const targetRemoteInfo = remotes.find((r) => r.name === selectedTargetRemote);
|
||||
if (targetRemoteInfo?.branches && targetRemoteInfo.branches.length > 0) {
|
||||
const targetBranchNames = new Set(targetRemoteInfo.branches);
|
||||
// Filter to only include branches that exist on the target remote
|
||||
// Match both short names (e.g. "main") and prefixed names (e.g. "upstream/main")
|
||||
return allBranches.filter((name) => {
|
||||
// Check if the branch name matches a target remote branch directly
|
||||
if (targetBranchNames.has(name)) return true;
|
||||
// Check if it's a prefixed remote branch (e.g. "upstream/main")
|
||||
const prefix = `${selectedTargetRemote}/`;
|
||||
if (name.startsWith(prefix) && targetBranchNames.has(name.slice(prefix.length)))
|
||||
return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allBranches;
|
||||
}, [branchesData?.branches, worktree?.branch, selectedTargetRemote, remotes]);
|
||||
|
||||
// When branches change (e.g. target remote changed), reset base branch if current selection is no longer valid
|
||||
useEffect(() => {
|
||||
if (branches.length > 0 && baseBranch && !branches.includes(baseBranch)) {
|
||||
// Current base branch is not in the filtered list — pick the best match
|
||||
const mainBranch = branches.find((b) => b === 'main' || b === 'master');
|
||||
setBaseBranch(mainBranch || branches[0]);
|
||||
}
|
||||
}, [branches, baseBranch]);
|
||||
|
||||
// Fetch remotes when dialog opens
|
||||
const fetchRemotes = useCallback(async () => {
|
||||
@@ -109,14 +157,15 @@ export function CreatePRDialog({
|
||||
|
||||
if (result.success && result.result) {
|
||||
const remoteInfos: RemoteInfo[] = result.result.remotes.map(
|
||||
(r: { name: string; url: string }) => ({
|
||||
(r: { name: string; url: string; branches?: { name: string }[] }) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
branches: r.branches?.map((b: { name: string }) => b.name) || [],
|
||||
})
|
||||
);
|
||||
setRemotes(remoteInfos);
|
||||
|
||||
// Preserve existing selection if it's still valid; otherwise fall back to 'origin' or first remote
|
||||
// Preserve existing push remote selection if it's still valid; otherwise fall back to 'origin' or first remote
|
||||
if (remoteInfos.length > 0) {
|
||||
const remoteNames = remoteInfos.map((r) => r.name);
|
||||
const currentSelection = selectedRemoteRef.current;
|
||||
@@ -126,6 +175,19 @@ export function CreatePRDialog({
|
||||
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
|
||||
setSelectedRemote(defaultRemote.name);
|
||||
}
|
||||
|
||||
// Preserve existing target remote selection if it's still valid
|
||||
const currentTargetSelection = selectedTargetRemoteRef.current;
|
||||
const currentTargetStillExists =
|
||||
currentTargetSelection !== '' && remoteNames.includes(currentTargetSelection);
|
||||
if (!currentTargetStillExists) {
|
||||
// Default target remote: 'upstream' if it exists (fork workflow), otherwise same as push remote
|
||||
const defaultTarget =
|
||||
remoteInfos.find((r) => r.name === 'upstream') ||
|
||||
remoteInfos.find((r) => r.name === 'origin') ||
|
||||
remoteInfos[0];
|
||||
setSelectedTargetRemote(defaultTarget.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -154,6 +216,7 @@ export function CreatePRDialog({
|
||||
setShowBrowserFallback(false);
|
||||
setRemotes([]);
|
||||
setSelectedRemote('');
|
||||
setSelectedTargetRemote('');
|
||||
setIsGeneratingDescription(false);
|
||||
operationCompletedRef.current = false;
|
||||
}, [defaultBaseBranch]);
|
||||
@@ -215,6 +278,7 @@ export function CreatePRDialog({
|
||||
baseBranch,
|
||||
draft: isDraft,
|
||||
remote: selectedRemote || undefined,
|
||||
targetRemote: remotes.length > 1 ? selectedTargetRemote || undefined : undefined,
|
||||
});
|
||||
|
||||
if (result.success && result.result) {
|
||||
@@ -348,7 +412,7 @@ export function CreatePRDialog({
|
||||
Create Pull Request
|
||||
</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
Push changes and create a pull request from{' '}
|
||||
{worktree.hasChanges ? 'Push changes and create' : 'Create'} a pull request from{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded break-all">{worktree.branch}</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -482,8 +546,8 @@ export function CreatePRDialog({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Remote selector - only show if multiple remotes are available */}
|
||||
{remotes.length > 1 && (
|
||||
{/* Push remote selector - only show when multiple remotes and there are commits to push */}
|
||||
{remotes.length > 1 && needsPush && (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="remote-select">Push to Remote</Label>
|
||||
@@ -525,14 +589,46 @@ export function CreatePRDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target remote selector - which remote to create PR against */}
|
||||
{remotes.length > 1 && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="target-remote-select">Create PR Against</Label>
|
||||
<Select value={selectedTargetRemote} onValueChange={setSelectedTargetRemote}>
|
||||
<SelectTrigger id="target-remote-select">
|
||||
<SelectValue placeholder="Select target remote" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remotes.map((remote) => (
|
||||
<SelectItem
|
||||
key={remote.name}
|
||||
value={remote.name}
|
||||
description={
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{remote.url}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The remote repository where the pull request will be created
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="base-branch">Base Branch</Label>
|
||||
<Label htmlFor="base-branch">Base Remote Branch</Label>
|
||||
<BranchAutocomplete
|
||||
value={baseBranch}
|
||||
onChange={setBaseBranch}
|
||||
branches={branches}
|
||||
placeholder="Select base branch..."
|
||||
disabled={isLoadingBranches}
|
||||
allowCreate={false}
|
||||
emptyMessage="No matching branches found."
|
||||
data-testid="base-branch-autocomplete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitBranch, AlertCircle } from 'lucide-react';
|
||||
import { GitBranch, AlertCircle, ChevronDown, ChevronRight, Globe, RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
@@ -100,6 +102,145 @@ export function CreateWorktreeDialog({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<{ title: string; description?: string } | null>(null);
|
||||
|
||||
// Base branch selection state
|
||||
const [showBaseBranch, setShowBaseBranch] = useState(false);
|
||||
const [baseBranch, setBaseBranch] = useState('');
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [availableBranches, setAvailableBranches] = useState<
|
||||
Array<{ name: string; isRemote: boolean }>
|
||||
>([]);
|
||||
// When the branch list fetch fails, store a message to show the user and
|
||||
// allow free-form branch entry via allowCreate as a fallback.
|
||||
const [branchFetchError, setBranchFetchError] = useState<string | null>(null);
|
||||
|
||||
// AbortController ref so in-flight branch fetches can be cancelled when the dialog closes
|
||||
const branchFetchAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Fetch available branches (local + remote) when the base branch section is expanded
|
||||
const fetchBranches = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
if (!projectPath) return;
|
||||
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
|
||||
// Fetch branches using the project path (use listBranches on the project root).
|
||||
// Pass the AbortSignal so controller.abort() cancels the in-flight HTTP request.
|
||||
const branchResult = await api.worktree.listBranches(projectPath, true, signal);
|
||||
|
||||
// If the fetch was aborted while awaiting, bail out to avoid stale state writes
|
||||
if (signal?.aborted) return;
|
||||
|
||||
if (branchResult.success && branchResult.result) {
|
||||
setBranchFetchError(null);
|
||||
setAvailableBranches(
|
||||
branchResult.result.branches.map((b: { name: string; isRemote: boolean }) => ({
|
||||
name: b.name,
|
||||
isRemote: b.isRemote,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
// API returned success: false — treat as an error
|
||||
const message =
|
||||
branchResult.error || 'Failed to load branches. You can type a branch name manually.';
|
||||
setBranchFetchError(message);
|
||||
setAvailableBranches([{ name: 'main', isRemote: false }]);
|
||||
}
|
||||
} catch (err) {
|
||||
// If aborted, don't update state
|
||||
if (signal?.aborted) return;
|
||||
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to load branches. You can type a branch name manually.';
|
||||
setBranchFetchError(message);
|
||||
// Provide 'main' as a safe fallback so the autocomplete is not empty,
|
||||
// and enable free-form entry (allowCreate) so the user can still type
|
||||
// any branch name when the remote list is unavailable.
|
||||
setAvailableBranches([{ name: 'main', isRemote: false }]);
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
// Fetch branches when the base branch section is expanded
|
||||
useEffect(() => {
|
||||
if (open && showBaseBranch) {
|
||||
// Abort any previous in-flight fetch
|
||||
branchFetchAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
branchFetchAbortRef.current = controller;
|
||||
fetchBranches(controller.signal);
|
||||
}
|
||||
return () => {
|
||||
branchFetchAbortRef.current?.abort();
|
||||
branchFetchAbortRef.current = null;
|
||||
};
|
||||
}, [open, showBaseBranch, fetchBranches]);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Abort any in-flight branch fetch to prevent stale writes
|
||||
branchFetchAbortRef.current?.abort();
|
||||
branchFetchAbortRef.current = null;
|
||||
|
||||
setBranchName('');
|
||||
setBaseBranch('');
|
||||
setShowBaseBranch(false);
|
||||
setError(null);
|
||||
setAvailableBranches([]);
|
||||
setBranchFetchError(null);
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Build branch name list for the autocomplete, with local branches first then remote
|
||||
const branchNames = useMemo(() => {
|
||||
const local: string[] = [];
|
||||
const remote: string[] = [];
|
||||
|
||||
for (const b of availableBranches) {
|
||||
if (b.isRemote) {
|
||||
// Skip bare remote refs without a branch name (e.g. "origin" by itself)
|
||||
if (!b.name.includes('/')) continue;
|
||||
remote.push(b.name);
|
||||
} else {
|
||||
local.push(b.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Local branches first, then remote branches
|
||||
return [...local, ...remote];
|
||||
}, [availableBranches]);
|
||||
|
||||
// Determine if the selected base branch is a remote branch.
|
||||
// Also detect manually entered remote-style names (e.g. "origin/feature")
|
||||
// so the UI shows the "Remote branch — will fetch latest" hint even when
|
||||
// the branch isn't in the fetched availableBranches list.
|
||||
const isRemoteBaseBranch = useMemo(() => {
|
||||
if (!baseBranch) return false;
|
||||
// If the branch list couldn't be fetched, availableBranches is a fallback
|
||||
// and may not reflect reality — suppress the remote hint to avoid misleading the user.
|
||||
if (branchFetchError) return false;
|
||||
// Check fetched branch list first
|
||||
const knownRemote = availableBranches.some((b) => b.name === baseBranch && b.isRemote);
|
||||
if (knownRemote) return true;
|
||||
// Heuristic: if the branch contains '/' and isn't a known local branch,
|
||||
// treat it as a remote ref (e.g. "origin/main")
|
||||
if (baseBranch.includes('/')) {
|
||||
const isKnownLocal = availableBranches.some((b) => b.name === baseBranch && !b.isRemote);
|
||||
return !isKnownLocal;
|
||||
}
|
||||
return false;
|
||||
}, [baseBranch, availableBranches, branchFetchError]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!branchName.trim()) {
|
||||
setError({ title: 'Branch name is required' });
|
||||
@@ -116,6 +257,17 @@ export function CreateWorktreeDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate baseBranch using the same allowed-character check as branchName to prevent
|
||||
// shell-special characters or invalid git ref names from reaching the API.
|
||||
const trimmedBaseBranch = baseBranch.trim();
|
||||
if (trimmedBaseBranch && !validBranchRegex.test(trimmedBaseBranch)) {
|
||||
setError({
|
||||
title: 'Invalid base branch name',
|
||||
description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -125,15 +277,22 @@ export function CreateWorktreeDialog({
|
||||
setError({ title: 'Worktree API not available' });
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.create(projectPath, branchName);
|
||||
|
||||
// Pass the validated baseBranch if one was selected (otherwise defaults to HEAD)
|
||||
const effectiveBaseBranch = trimmedBaseBranch || undefined;
|
||||
const result = await api.worktree.create(projectPath, branchName, effectiveBaseBranch);
|
||||
|
||||
if (result.success && result.worktree) {
|
||||
const baseDesc = effectiveBaseBranch ? ` from ${effectiveBaseBranch}` : '';
|
||||
toast.success(`Worktree created for branch "${result.worktree.branch}"`, {
|
||||
description: result.worktree.isNew ? 'New branch created' : 'Using existing branch',
|
||||
description: result.worktree.isNew
|
||||
? `New branch created${baseDesc}`
|
||||
: 'Using existing branch',
|
||||
});
|
||||
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
|
||||
onOpenChange(false);
|
||||
setBranchName('');
|
||||
setBaseBranch('');
|
||||
} else {
|
||||
setError(parseWorktreeError(result.error || 'Failed to create worktree'));
|
||||
}
|
||||
@@ -154,7 +313,7 @@ export function CreateWorktreeDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitBranch className="w-5 h-5" />
|
||||
@@ -181,19 +340,96 @@ export function CreateWorktreeDialog({
|
||||
className="font-mono text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
|
||||
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">{error.title}</p>
|
||||
{error.description && (
|
||||
<p className="text-xs text-destructive/80">{error.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Base Branch Section - collapsible */}
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBaseBranch(!showBaseBranch)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
{showBaseBranch ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>Base Branch</span>
|
||||
{baseBranch && !showBaseBranch && (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono ml-1">
|
||||
{baseBranch}
|
||||
</code>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showBaseBranch && (
|
||||
<div className="grid gap-2 pl-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a local or remote branch as the starting point
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
branchFetchAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
branchFetchAbortRef.current = controller;
|
||||
void fetchBranches(controller.signal);
|
||||
}}
|
||||
disabled={isLoadingBranches}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{isLoadingBranches ? (
|
||||
<Spinner size="xs" className="mr-1" />
|
||||
) : (
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{branchFetchError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-destructive">
|
||||
<AlertCircle className="w-3 h-3 flex-shrink-0" />
|
||||
<span>Could not load branches: {branchFetchError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BranchAutocomplete
|
||||
value={baseBranch}
|
||||
onChange={(value) => {
|
||||
setBaseBranch(value);
|
||||
setError(null);
|
||||
}}
|
||||
branches={branchNames}
|
||||
placeholder="Select base branch (default: HEAD)..."
|
||||
disabled={isLoadingBranches}
|
||||
allowCreate={!!branchFetchError}
|
||||
/>
|
||||
|
||||
{isRemoteBaseBranch && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Globe className="w-3 h-3" />
|
||||
<span>Remote branch — will fetch latest before creating worktree</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
|
||||
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">{error.title}</p>
|
||||
{error.description && (
|
||||
<p className="text-xs text-destructive/80">{error.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>Examples:</p>
|
||||
<ul className="list-disc list-inside pl-2 space-y-0.5">
|
||||
@@ -218,7 +454,7 @@ export function CreateWorktreeDialog({
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating...
|
||||
{isRemoteBaseBranch ? 'Fetching & Creating...' : 'Creating...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -24,8 +24,8 @@ interface MergeWorktreeDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectPath: string;
|
||||
worktree: WorktreeInfo | null;
|
||||
/** Called when merge is successful. deletedBranch indicates if the branch was also deleted. */
|
||||
onMerged: (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||
/** Called when integration is successful. integratedWorktree indicates the integrated worktree and deletedBranch indicates if the branch was also deleted. */
|
||||
onIntegrated: (integratedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function MergeWorktreeDialog({
|
||||
onOpenChange,
|
||||
projectPath,
|
||||
worktree,
|
||||
onMerged,
|
||||
onIntegrated,
|
||||
onCreateConflictResolutionFeature,
|
||||
}: MergeWorktreeDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -105,10 +105,10 @@ export function MergeWorktreeDialog({
|
||||
|
||||
if (result.success) {
|
||||
const description = deleteWorktreeAndBranch
|
||||
? `Branch "${worktree.branch}" has been merged into "${targetBranch}" and the worktree and branch were deleted`
|
||||
: `Branch "${worktree.branch}" has been merged into "${targetBranch}"`;
|
||||
toast.success(`Branch merged to ${targetBranch}`, { description });
|
||||
onMerged(worktree, deleteWorktreeAndBranch);
|
||||
? `Branch "${worktree.branch}" has been integrated into "${targetBranch}" and the worktree and branch were deleted`
|
||||
: `Branch "${worktree.branch}" has been integrated into "${targetBranch}"`;
|
||||
toast.success(`Branch integrated into ${targetBranch}`, { description });
|
||||
onIntegrated(worktree, deleteWorktreeAndBranch);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
// Check if the error indicates merge conflicts
|
||||
@@ -128,11 +128,11 @@ export function MergeWorktreeDialog({
|
||||
conflictFiles: result.conflictFiles || [],
|
||||
operationType: 'merge',
|
||||
});
|
||||
toast.error('Merge conflicts detected', {
|
||||
toast.error('Integrate conflicts detected', {
|
||||
description: 'Choose how to resolve the conflicts below.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to merge branch', {
|
||||
toast.error('Failed to integrate branch', {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
@@ -153,11 +153,11 @@ export function MergeWorktreeDialog({
|
||||
conflictFiles: [],
|
||||
operationType: 'merge',
|
||||
});
|
||||
toast.error('Merge conflicts detected', {
|
||||
toast.error('Integrate conflicts detected', {
|
||||
description: 'Choose how to resolve the conflicts below.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to merge branch', {
|
||||
toast.error('Failed to integrate branch', {
|
||||
description: errorMessage,
|
||||
});
|
||||
}
|
||||
@@ -191,12 +191,12 @@ export function MergeWorktreeDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
Merge Conflicts Detected
|
||||
Integrate Conflicts Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<span className="block">
|
||||
There are conflicts when merging{' '}
|
||||
There are conflicts when integrating{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">
|
||||
{mergeConflict.sourceBranch}
|
||||
</code>{' '}
|
||||
@@ -274,12 +274,12 @@ export function MergeWorktreeDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitMerge className="w-5 h-5 text-green-600" />
|
||||
Merge Branch
|
||||
Integrate Branch
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<span className="block">
|
||||
Merge <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
|
||||
Integrate <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
|
||||
into:
|
||||
</span>
|
||||
|
||||
@@ -308,7 +308,7 @@ export function MergeWorktreeDialog({
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-yellow-500 text-sm">
|
||||
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
|
||||
commit or discard them before merging.
|
||||
commit or discard them before integrating.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -327,7 +327,7 @@ export function MergeWorktreeDialog({
|
||||
className="text-sm cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
||||
Delete worktree and branch after merging
|
||||
Delete worktree and branch after integrating
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
@@ -353,12 +353,12 @@ export function MergeWorktreeDialog({
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Merging...
|
||||
Integrating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitMerge className="w-4 h-4 mr-2" />
|
||||
Merge
|
||||
Integrate
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -251,7 +251,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] 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-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitCommit className="w-5 h-5" />
|
||||
|
||||
@@ -367,7 +367,7 @@ export function ViewStashesDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] 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-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Archive className="w-5 h-5" />
|
||||
|
||||
@@ -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-[100dvh] 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 dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
|
||||
@@ -123,6 +123,7 @@ export function useBoardActions({
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
workMode?: 'current' | 'auto' | 'custom';
|
||||
initialStatus?: 'backlog' | 'in_progress'; // Skip backlog flash when creating & starting immediately
|
||||
}) => {
|
||||
const workMode = featureData.workMode || 'current';
|
||||
|
||||
@@ -218,13 +219,15 @@ export function useBoardActions({
|
||||
const needsTitleGeneration =
|
||||
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
|
||||
|
||||
const initialStatus = featureData.initialStatus || 'backlog';
|
||||
const newFeatureData = {
|
||||
...featureData,
|
||||
title: titleWasGenerated ? titleForBranch : featureData.title,
|
||||
titleGenerating: needsTitleGeneration,
|
||||
status: 'backlog' as const,
|
||||
status: initialStatus,
|
||||
branchName: finalBranchName,
|
||||
dependencies: featureData.dependencies || [],
|
||||
...(initialStatus === 'in_progress' ? { startedAt: new Date().toISOString() } : {}),
|
||||
};
|
||||
const createdFeature = addFeature(newFeatureData);
|
||||
// Must await to ensure feature exists on server before user can drag it
|
||||
@@ -608,20 +611,51 @@ export function useBoardActions({
|
||||
}
|
||||
}
|
||||
|
||||
const updates = {
|
||||
status: 'in_progress' as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
// Skip status update if feature was already created with in_progress status
|
||||
// (e.g., via "Make" button which creates directly as in_progress to avoid backlog flash)
|
||||
const alreadyInProgress = feature.status === 'in_progress';
|
||||
|
||||
if (!alreadyInProgress) {
|
||||
const updates = {
|
||||
status: 'in_progress' as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
|
||||
try {
|
||||
// Must await to ensure feature status is persisted before starting agent
|
||||
await persistFeatureUpdate(feature.id, updates);
|
||||
} catch (error) {
|
||||
// Rollback to backlog if persist fails (e.g., server offline)
|
||||
logger.error('Failed to update feature status, rolling back to backlog:', error);
|
||||
const rollbackUpdates = {
|
||||
status: 'backlog' as const,
|
||||
startedAt: undefined,
|
||||
};
|
||||
updateFeature(feature.id, rollbackUpdates);
|
||||
persistFeatureUpdate(feature.id, rollbackUpdates).catch((persistError) => {
|
||||
logger.error('Failed to persist rollback:', persistError);
|
||||
});
|
||||
|
||||
if (isConnectionError(error)) {
|
||||
handleServerOffline();
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.error('Failed to start feature', {
|
||||
description:
|
||||
error instanceof Error ? error.message : 'Server may be offline. Please try again.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Must await to ensure feature status is persisted before starting agent
|
||||
await persistFeatureUpdate(feature.id, updates);
|
||||
logger.info('Feature moved to in_progress, starting agent...');
|
||||
await handleRunFeature(feature);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Rollback to backlog if persist or run fails (e.g., server offline)
|
||||
// Rollback to backlog if run fails
|
||||
logger.error('Failed to start feature, rolling back to backlog:', error);
|
||||
const rollbackUpdates = {
|
||||
status: 'backlog' as const,
|
||||
|
||||
@@ -12,6 +12,7 @@ type ColumnId = Feature['status'];
|
||||
interface UseBoardColumnFeaturesProps {
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
runningAutoTasksAllWorktrees: string[]; // Running tasks across ALL worktrees (prevents backlog flash during event timing gaps)
|
||||
searchQuery: string;
|
||||
currentWorktreePath: string | null; // Currently selected worktree path
|
||||
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
|
||||
@@ -21,6 +22,7 @@ interface UseBoardColumnFeaturesProps {
|
||||
export function useBoardColumnFeatures({
|
||||
features,
|
||||
runningAutoTasks,
|
||||
runningAutoTasksAllWorktrees,
|
||||
searchQuery,
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch,
|
||||
@@ -38,6 +40,10 @@ export function useBoardColumnFeatures({
|
||||
};
|
||||
const featureMap = createFeatureMap(features);
|
||||
const runningTaskIds = new Set(runningAutoTasks);
|
||||
// Track ALL running tasks across all worktrees to prevent features from
|
||||
// briefly appearing in backlog during the timing gap between when the server
|
||||
// starts executing a feature and when the UI receives the event/status update.
|
||||
const allRunningTaskIds = new Set(runningAutoTasksAllWorktrees);
|
||||
|
||||
// Filter features by search query (case-insensitive)
|
||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||
@@ -138,11 +144,28 @@ export function useBoardColumnFeatures({
|
||||
return;
|
||||
}
|
||||
|
||||
// Not running: place by status (and worktree filter)
|
||||
// Not running (on this worktree): place by status (and worktree filter)
|
||||
// Filter all items by worktree, including backlog
|
||||
// This ensures backlog items with a branch assigned only show in that branch
|
||||
if (status === 'backlog') {
|
||||
if (matchesWorktree) {
|
||||
//
|
||||
// 'ready' and 'interrupted' are transitional statuses that don't have dedicated columns:
|
||||
// - 'ready': Feature has an approved plan, waiting to be picked up for execution
|
||||
// - 'interrupted': Feature execution was aborted (e.g., user stopped it, server restart)
|
||||
// Both display in the backlog column and need the same allRunningTaskIds race-condition
|
||||
// protection as 'backlog' to prevent briefly flashing in backlog when already executing.
|
||||
if (status === 'backlog' || status === 'ready' || status === 'interrupted') {
|
||||
// IMPORTANT: Check if this feature is running on ANY worktree before placing in backlog.
|
||||
// This prevents a race condition where the feature has started executing on the server
|
||||
// (and is tracked in a different worktree's running list) but the disk status hasn't
|
||||
// been updated yet or the UI hasn't received the worktree-scoped event.
|
||||
// In that case, the feature would briefly flash in the backlog column.
|
||||
if (allRunningTaskIds.has(f.id)) {
|
||||
// Feature is running somewhere - show in in_progress if it matches this worktree,
|
||||
// otherwise skip it (it will appear on the correct worktree's board)
|
||||
if (matchesWorktree) {
|
||||
map.in_progress.push(f);
|
||||
}
|
||||
} else if (matchesWorktree) {
|
||||
map.backlog.push(f);
|
||||
}
|
||||
} else if (map[status]) {
|
||||
@@ -159,8 +182,12 @@ export function useBoardColumnFeatures({
|
||||
map[status].push(f);
|
||||
}
|
||||
} else {
|
||||
// Unknown status, default to backlog
|
||||
if (matchesWorktree) {
|
||||
// Unknown status - apply same allRunningTaskIds protection and default to backlog
|
||||
if (allRunningTaskIds.has(f.id)) {
|
||||
if (matchesWorktree) {
|
||||
map.in_progress.push(f);
|
||||
}
|
||||
} else if (matchesWorktree) {
|
||||
map.backlog.push(f);
|
||||
}
|
||||
}
|
||||
@@ -199,6 +226,7 @@ export function useBoardColumnFeatures({
|
||||
}, [
|
||||
features,
|
||||
runningAutoTasks,
|
||||
runningAutoTasksAllWorktrees,
|
||||
searchQuery,
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useQueryClient, useIsRestoring } from '@tanstack/react-query';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
@@ -24,13 +24,24 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
||||
|
||||
// Track whether React Query's IDB persistence layer is still restoring.
|
||||
// During the restore window (~100-500ms on mobile), queries report
|
||||
// isLoading=true because no data is in the cache yet. We suppress
|
||||
// the full-screen spinner during this period to avoid a visible flash
|
||||
// on PWA memory-eviction cold starts.
|
||||
const isRestoring = useIsRestoring();
|
||||
|
||||
// Use React Query for features
|
||||
const {
|
||||
data: features = [],
|
||||
isLoading,
|
||||
isLoading: isQueryLoading,
|
||||
refetch: loadFeatures,
|
||||
} = useFeatures(currentProject?.path);
|
||||
|
||||
// Don't report loading while IDB cache restore is in progress —
|
||||
// features will appear momentarily once the restore completes.
|
||||
const isLoading = isQueryLoading && !isRestoring;
|
||||
|
||||
// Load persisted categories from file
|
||||
const loadCategories = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
@@ -320,13 +320,13 @@ export function KanbanBoard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 overflow-x-auto px-5 pt-2 sm:pt-4 pb-1 sm:pb-4 relative',
|
||||
'flex-1 overflow-x-auto px-5 pt-2 sm:pt-4 pb-0 sm:pb-4 relative',
|
||||
'transition-opacity duration-200',
|
||||
className
|
||||
)}
|
||||
style={backgroundImageStyle}
|
||||
>
|
||||
<div className="h-full py-1" style={containerStyle}>
|
||||
<div className="h-full pt-1 pb-0 sm:pb-1" style={containerStyle}>
|
||||
{columns.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
||||
return (
|
||||
|
||||
@@ -131,7 +131,7 @@ export function DevServerLogsPanel({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent
|
||||
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
|
||||
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden dialog-fullscreen-mobile"
|
||||
data-testid="dev-server-logs-panel"
|
||||
compact
|
||||
>
|
||||
|
||||
@@ -81,10 +81,18 @@ interface WorktreeActionsDropdownProps {
|
||||
isTestRunning?: boolean;
|
||||
/** Active test session info for this worktree */
|
||||
testSessionInfo?: TestSessionInfo;
|
||||
/** List of available remotes for this worktree (used to show remote submenu) */
|
||||
remotes?: Array<{ name: string; url: string }>;
|
||||
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
|
||||
trackingRemote?: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onPushNewBranch: (worktree: WorktreeInfo) => void;
|
||||
/** Pull from a specific remote, bypassing the remote selection dialog */
|
||||
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||
/** Push to a specific remote, bypassing the remote selection dialog */
|
||||
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
@@ -141,10 +149,14 @@ export function WorktreeActionsDropdown({
|
||||
isStartingTests = false,
|
||||
isTestRunning = false,
|
||||
testSessionInfo,
|
||||
remotes,
|
||||
trackingRemote,
|
||||
onOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
onPushNewBranch,
|
||||
onPullWithRemote,
|
||||
onPushWithRemote,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
@@ -217,9 +229,11 @@ export function WorktreeActionsDropdown({
|
||||
: null;
|
||||
|
||||
// Determine if the changes/PR section has any visible items
|
||||
const showCreatePR = (!worktree.isMain || worktree.hasChanges) && !hasPR;
|
||||
// Show Create PR when no existing PR is linked
|
||||
const showCreatePR = !hasPR;
|
||||
const showPRInfo = hasPR && !!worktree.pr;
|
||||
const hasChangesSectionContent = worktree.hasChanges || showCreatePR || showPRInfo;
|
||||
const hasChangesSectionContent =
|
||||
worktree.hasChanges || showCreatePR || showPRInfo || !!(onStashChanges || onViewStashes);
|
||||
|
||||
// Determine if the destructive/bottom section has any visible items
|
||||
const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain;
|
||||
@@ -317,6 +331,25 @@ export function WorktreeActionsDropdown({
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{/* Auto Mode toggle */}
|
||||
{onToggleAutoMode && (
|
||||
<>
|
||||
{isAutoModeRunning ? (
|
||||
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
||||
<span className="flex items-center mr-2">
|
||||
<Zap className="w-3.5 h-3.5 text-yellow-500" />
|
||||
<span className="ml-1.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</span>
|
||||
Stop Auto Mode
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
||||
<Zap className="w-3.5 h-3.5 mr-2" />
|
||||
Start Auto Mode
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isDevServerRunning ? (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||
@@ -416,188 +449,6 @@ export function WorktreeActionsDropdown({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Auto Mode toggle */}
|
||||
{onToggleAutoMode && (
|
||||
<>
|
||||
{isAutoModeRunning ? (
|
||||
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
||||
<span className="flex items-center mr-2">
|
||||
<Zap className="w-3.5 h-3.5 text-yellow-500" />
|
||||
<span className="ml-1.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</span>
|
||||
Stop Auto Mode
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
||||
<Zap className="w-3.5 h-3.5 mr-2" />
|
||||
Start Auto Mode
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => isGitOpsAvailable && onPull(worktree)}
|
||||
disabled={isPulling || !isGitOpsAvailable}
|
||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
|
||||
{isPulling ? 'Pulling...' : 'Pull'}
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
{isGitOpsAvailable && behindCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||
{behindCount} behind
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!isGitOpsAvailable) return;
|
||||
if (!hasRemoteBranch) {
|
||||
onPushNewBranch(worktree);
|
||||
} else {
|
||||
onPush(worktree);
|
||||
}
|
||||
}}
|
||||
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable}
|
||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
||||
{isPushing ? 'Pushing...' : 'Push'}
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
{isGitOpsAvailable && !hasRemoteBranch && (
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
|
||||
<CloudOff className="w-2.5 h-2.5" />
|
||||
local only
|
||||
</span>
|
||||
)}
|
||||
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
{aheadCount} ahead
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn(
|
||||
'text-xs text-purple-500 focus:text-purple-600',
|
||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Merge & Rebase
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<History className="w-3.5 h-3.5 mr-2" />
|
||||
View Commits
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
{/* Cherry-pick commits from another branch */}
|
||||
{onCherryPick && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Cherry className="w-3.5 h-3.5 mr-2" />
|
||||
Cherry Pick
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{/* Stash operations - combined submenu or simple item */}
|
||||
{(onStashChanges || onViewStashes) && (
|
||||
<TooltipWrapper showTooltip={!isGitOpsAvailable} tooltipContent={gitOpsDisabledReason}>
|
||||
{onViewStashes && worktree.hasChanges && onStashChanges ? (
|
||||
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - stash changes (primary action) */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!isGitOpsAvailable) return;
|
||||
onStashChanges(worktree);
|
||||
}}
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn(
|
||||
'text-xs flex-1 pr-0 rounded-r-none',
|
||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||
Stash Changes
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{/* Chevron trigger for submenu with stash options */}
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(
|
||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
disabled={!isGitOpsAvailable}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||
View Stashes
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
// Only one action is meaningful - render a simple menu item without submenu
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!isGitOpsAvailable) return;
|
||||
if (worktree.hasChanges && onStashChanges) {
|
||||
onStashChanges(worktree);
|
||||
} else if (onViewStashes) {
|
||||
onViewStashes(worktree);
|
||||
}
|
||||
}}
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{/* Open in editor - split button: click main area for default, chevron for other options */}
|
||||
{effectiveDefaultEditor && (
|
||||
<DropdownMenuSub>
|
||||
@@ -723,6 +574,272 @@ export function WorktreeActionsDropdown({
|
||||
Re-run Init Script
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
{remotes && remotes.length > 1 && onPullWithRemote ? (
|
||||
// Multiple remotes - show split button: click main area to pull (default behavior),
|
||||
// chevron opens submenu showing individual remotes to pull from
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenuItem
|
||||
onClick={() => isGitOpsAvailable && onPull(worktree)}
|
||||
disabled={isPulling || !isGitOpsAvailable}
|
||||
className={cn(
|
||||
'text-xs flex-1 pr-0 rounded-r-none',
|
||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
|
||||
{isPulling ? 'Pulling...' : 'Pull'}
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
{isGitOpsAvailable && behindCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||
{behindCount} behind
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(
|
||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||
(!isGitOpsAvailable || isPulling) && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
disabled={!isGitOpsAvailable || isPulling}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
|
||||
Pull from remote
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{remotes.map((remote) => (
|
||||
<DropdownMenuItem
|
||||
key={remote.name}
|
||||
onClick={() => isGitOpsAvailable && onPullWithRemote(worktree, remote.name)}
|
||||
disabled={isPulling || !isGitOpsAvailable}
|
||||
className="text-xs"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 mr-2" />
|
||||
{remote.name}
|
||||
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
|
||||
{remote.url}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
// Single remote or no remotes - show simple menu item
|
||||
<DropdownMenuItem
|
||||
onClick={() => isGitOpsAvailable && onPull(worktree)}
|
||||
disabled={isPulling || !isGitOpsAvailable}
|
||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
|
||||
{isPulling ? 'Pulling...' : 'Pull'}
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
{isGitOpsAvailable && behindCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||
{behindCount} behind
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
{remotes && remotes.length > 1 && onPushWithRemote ? (
|
||||
// Multiple remotes - show split button: click main area for default push behavior,
|
||||
// chevron opens submenu showing individual remotes to push to
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!isGitOpsAvailable) return;
|
||||
if (!hasRemoteBranch) {
|
||||
onPushNewBranch(worktree);
|
||||
} else {
|
||||
onPush(worktree);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable
|
||||
}
|
||||
className={cn(
|
||||
'text-xs flex-1 pr-0 rounded-r-none',
|
||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
||||
{isPushing ? 'Pushing...' : 'Push'}
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
{isGitOpsAvailable && !hasRemoteBranch && (
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
|
||||
<CloudOff className="w-2.5 h-2.5" />
|
||||
local only
|
||||
</span>
|
||||
)}
|
||||
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
{aheadCount} ahead
|
||||
</span>
|
||||
)}
|
||||
{isGitOpsAvailable && hasRemoteBranch && trackingRemote && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',
|
||||
aheadCount > 0 ? 'ml-1' : 'ml-auto'
|
||||
)}
|
||||
>
|
||||
{trackingRemote}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(
|
||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||
(!isGitOpsAvailable || isPushing) && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
disabled={!isGitOpsAvailable || isPushing}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
|
||||
Push to remote
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{remotes.map((remote) => (
|
||||
<DropdownMenuItem
|
||||
key={remote.name}
|
||||
onClick={() => isGitOpsAvailable && onPushWithRemote(worktree, remote.name)}
|
||||
disabled={isPushing || !isGitOpsAvailable}
|
||||
className="text-xs"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5 mr-2" />
|
||||
{remote.name}
|
||||
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
|
||||
{remote.url}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
// Single remote or no remotes - show simple menu item
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!isGitOpsAvailable) return;
|
||||
if (!hasRemoteBranch) {
|
||||
onPushNewBranch(worktree);
|
||||
} else {
|
||||
onPush(worktree);
|
||||
}
|
||||
}}
|
||||
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable}
|
||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
||||
{isPushing ? 'Pushing...' : 'Push'}
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
{isGitOpsAvailable && !hasRemoteBranch && (
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
|
||||
<CloudOff className="w-2.5 h-2.5" />
|
||||
local only
|
||||
</span>
|
||||
)}
|
||||
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
{aheadCount} ahead
|
||||
</span>
|
||||
)}
|
||||
{isGitOpsAvailable && hasRemoteBranch && trackingRemote && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',
|
||||
aheadCount > 0 ? 'ml-1' : 'ml-auto'
|
||||
)}
|
||||
>
|
||||
{trackingRemote}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn(
|
||||
'text-xs text-purple-500 focus:text-purple-600',
|
||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Merge & Rebase
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
{!worktree.isMain && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => isGitOpsAvailable && onMerge(worktree)}
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn(
|
||||
'text-xs text-green-600 focus:text-green-700',
|
||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Integrate Branch
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<History className="w-3.5 h-3.5 mr-2" />
|
||||
View Commits
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
{/* Cherry-pick commits from another branch */}
|
||||
{onCherryPick && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Cherry className="w-3.5 h-3.5 mr-2" />
|
||||
Cherry Pick
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
|
||||
|
||||
{worktree.hasChanges && (
|
||||
@@ -731,6 +848,75 @@ export function WorktreeActionsDropdown({
|
||||
View Changes
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Stash operations - combined submenu or simple item.
|
||||
Only render when at least one action is meaningful:
|
||||
- (worktree.hasChanges && onStashChanges): stashing changes is possible
|
||||
- onViewStashes: viewing existing stashes is possible
|
||||
Without this guard, the item would appear clickable but be a silent no-op
|
||||
when hasChanges is false and onViewStashes is undefined. */}
|
||||
{((worktree.hasChanges && onStashChanges) || onViewStashes) && (
|
||||
<TooltipWrapper showTooltip={!isGitOpsAvailable} tooltipContent={gitOpsDisabledReason}>
|
||||
{onViewStashes && worktree.hasChanges && onStashChanges ? (
|
||||
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - stash changes (primary action) */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!isGitOpsAvailable) return;
|
||||
onStashChanges(worktree);
|
||||
}}
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn(
|
||||
'text-xs flex-1 pr-0 rounded-r-none',
|
||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||
Stash Changes
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{/* Chevron trigger for submenu with stash options */}
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(
|
||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
disabled={!isGitOpsAvailable}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||
View Stashes
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
// Only one action is meaningful - render a simple menu item without submenu
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!isGitOpsAvailable) return;
|
||||
if (worktree.hasChanges && onStashChanges) {
|
||||
onStashChanges(worktree);
|
||||
} else if (onViewStashes) {
|
||||
onViewStashes(worktree);
|
||||
}
|
||||
}}
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{worktree.hasChanges && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
@@ -749,7 +935,7 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
||||
{/* Show PR option when there is no existing PR (showCreatePR === !hasPR) */}
|
||||
{showCreatePR && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
@@ -829,35 +1015,13 @@ export function WorktreeActionsDropdown({
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{!worktree.isMain && (
|
||||
<>
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => isGitOpsAvailable && onMerge(worktree)}
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn(
|
||||
'text-xs text-green-600 focus:text-green-700',
|
||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Merge Branch
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteWorktree(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
||||
Delete Worktree
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteWorktree(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
||||
Delete Worktree
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -82,6 +82,10 @@ export interface WorktreeDropdownProps {
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
hasRemoteBranch: boolean;
|
||||
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
|
||||
trackingRemote?: string;
|
||||
/** Per-worktree tracking remote lookup */
|
||||
getTrackingRemote?: (worktreePath: string) => string | undefined;
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
hasTestCommand: boolean;
|
||||
isStartingTests: boolean;
|
||||
@@ -121,6 +125,12 @@ export interface WorktreeDropdownProps {
|
||||
onAbortOperation?: (worktree: WorktreeInfo) => void;
|
||||
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
|
||||
onContinueOperation?: (worktree: WorktreeInfo) => void;
|
||||
/** Remotes cache: maps worktree path to list of remotes */
|
||||
remotesCache?: Record<string, Array<{ name: string; url: string }>>;
|
||||
/** Pull from a specific remote, bypassing the remote selection dialog */
|
||||
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||
/** Push to a specific remote, bypassing the remote selection dialog */
|
||||
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,6 +180,8 @@ export function WorktreeDropdown({
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
trackingRemote,
|
||||
getTrackingRemote,
|
||||
gitRepoStatus,
|
||||
hasTestCommand,
|
||||
isStartingTests,
|
||||
@@ -204,6 +216,9 @@ export function WorktreeDropdown({
|
||||
onCherryPick,
|
||||
onAbortOperation,
|
||||
onContinueOperation,
|
||||
remotesCache,
|
||||
onPullWithRemote,
|
||||
onPushWithRemote,
|
||||
}: WorktreeDropdownProps) {
|
||||
// Find the currently selected worktree to display in the trigger
|
||||
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
|
||||
@@ -470,6 +485,9 @@ export function WorktreeDropdown({
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
trackingRemote={
|
||||
getTrackingRemote ? getTrackingRemote(selectedWorktree.path) : trackingRemote
|
||||
}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
@@ -482,10 +500,13 @@ export function WorktreeDropdown({
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
||||
remotes={remotesCache?.[selectedWorktree.path]}
|
||||
onOpenChange={onActionsDropdownOpenChange(selectedWorktree)}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onPushNewBranch={onPushNewBranch}
|
||||
onPullWithRemote={onPullWithRemote}
|
||||
onPushWithRemote={onPushWithRemote}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||
|
||||
@@ -38,6 +38,8 @@ interface WorktreeTabProps {
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
hasRemoteBranch: boolean;
|
||||
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
|
||||
trackingRemote?: string;
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
/** Whether auto mode is running for this worktree */
|
||||
isAutoModeRunning?: boolean;
|
||||
@@ -93,6 +95,12 @@ interface WorktreeTabProps {
|
||||
hasInitScript: boolean;
|
||||
/** Whether a test command is configured in project settings */
|
||||
hasTestCommand?: boolean;
|
||||
/** List of available remotes for this worktree (used to show remote submenu) */
|
||||
remotes?: Array<{ name: string; url: string }>;
|
||||
/** Pull from a specific remote, bypassing the remote selection dialog */
|
||||
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||
/** Push to a specific remote, bypassing the remote selection dialog */
|
||||
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||
}
|
||||
|
||||
export function WorktreeTab({
|
||||
@@ -116,6 +124,7 @@ export function WorktreeTab({
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
trackingRemote,
|
||||
gitRepoStatus,
|
||||
isAutoModeRunning = false,
|
||||
isStartingTests = false,
|
||||
@@ -158,6 +167,9 @@ export function WorktreeTab({
|
||||
onContinueOperation,
|
||||
hasInitScript,
|
||||
hasTestCommand = false,
|
||||
remotes,
|
||||
onPullWithRemote,
|
||||
onPushWithRemote,
|
||||
}: WorktreeTabProps) {
|
||||
// Make the worktree tab a drop target for feature cards
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
@@ -476,6 +488,7 @@ export function WorktreeTab({
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
trackingRemote={trackingRemote}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
@@ -488,10 +501,13 @@ export function WorktreeTab({
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunning}
|
||||
testSessionInfo={testSessionInfo}
|
||||
remotes={remotes}
|
||||
onOpenChange={onActionsDropdownOpenChange}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onPushNewBranch={onPushNewBranch}
|
||||
onPullWithRemote={onPullWithRemote}
|
||||
onPushWithRemote={onPushWithRemote}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useWorktreeBranches } from '@/hooks/queries';
|
||||
import type { GitRepoStatus } from '../types';
|
||||
|
||||
/** Explicit return type for the useBranches hook */
|
||||
export interface UseBranchesReturn {
|
||||
branches: Array<{ name: string; isCurrent: boolean; isRemote: boolean }>;
|
||||
filteredBranches: Array<{ name: string; isCurrent: boolean; isRemote: boolean }>;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
hasRemoteBranch: boolean;
|
||||
/**
|
||||
* @deprecated Use {@link getTrackingRemote}(worktreePath) instead — this value
|
||||
* only reflects the last-queried worktree and is unreliable when multiple panels
|
||||
* share the hook.
|
||||
*/
|
||||
trackingRemote: string | undefined;
|
||||
/** Per-worktree tracking remote lookup — avoids stale values when multiple panels share the hook */
|
||||
getTrackingRemote: (worktreePath: string) => string | undefined;
|
||||
isLoadingBranches: boolean;
|
||||
branchFilter: string;
|
||||
setBranchFilter: (filter: string) => void;
|
||||
resetBranchFilter: () => void;
|
||||
fetchBranches: (worktreePath: string) => void;
|
||||
/** Prune cached tracking-remote entries for worktree paths that no longer exist */
|
||||
pruneStaleEntries: (activePaths: Set<string>) => void;
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing branch data with React Query
|
||||
*
|
||||
@@ -9,7 +34,7 @@ import type { GitRepoStatus } from '../types';
|
||||
* the current interface for backward compatibility. Tracks which
|
||||
* worktree path is currently being viewed and fetches branches on demand.
|
||||
*/
|
||||
export function useBranches() {
|
||||
export function useBranches(): UseBranchesReturn {
|
||||
const [currentWorktreePath, setCurrentWorktreePath] = useState<string | undefined>();
|
||||
const [branchFilter, setBranchFilter] = useState('');
|
||||
|
||||
@@ -23,6 +48,31 @@ export function useBranches() {
|
||||
const aheadCount = branchData?.aheadCount ?? 0;
|
||||
const behindCount = branchData?.behindCount ?? 0;
|
||||
const hasRemoteBranch = branchData?.hasRemoteBranch ?? false;
|
||||
const trackingRemote = branchData?.trackingRemote;
|
||||
|
||||
// Per-worktree tracking remote cache: keeps results from previous fetchBranches()
|
||||
// calls so multiple WorktreePanel instances don't all share a single stale value.
|
||||
const trackingRemoteByPathRef = useRef<Record<string, string | undefined>>({});
|
||||
|
||||
// Update cache whenever query data changes for the current path
|
||||
useEffect(() => {
|
||||
if (currentWorktreePath && branchData) {
|
||||
trackingRemoteByPathRef.current[currentWorktreePath] = branchData.trackingRemote;
|
||||
}
|
||||
}, [currentWorktreePath, branchData]);
|
||||
|
||||
const getTrackingRemote = useCallback(
|
||||
(worktreePath: string): string | undefined => {
|
||||
// If asking about the currently active query path, use fresh data
|
||||
if (worktreePath === currentWorktreePath) {
|
||||
return trackingRemote;
|
||||
}
|
||||
// Otherwise fall back to the cached value from a previous fetch
|
||||
return trackingRemoteByPathRef.current[worktreePath];
|
||||
},
|
||||
[currentWorktreePath, trackingRemote]
|
||||
);
|
||||
|
||||
// Use conservative defaults (false) until data is confirmed
|
||||
// This prevents the UI from assuming git capabilities before the query completes
|
||||
const gitRepoStatus: GitRepoStatus = {
|
||||
@@ -47,6 +97,16 @@ export function useBranches() {
|
||||
setBranchFilter('');
|
||||
}, []);
|
||||
|
||||
/** Remove cached tracking-remote entries for worktree paths that no longer exist. */
|
||||
const pruneStaleEntries = useCallback((activePaths: Set<string>) => {
|
||||
const cache = trackingRemoteByPathRef.current;
|
||||
for (const key of Object.keys(cache)) {
|
||||
if (!activePaths.has(key)) {
|
||||
delete cache[key];
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredBranches = branches.filter((b) =>
|
||||
b.name.toLowerCase().includes(branchFilter.toLowerCase())
|
||||
);
|
||||
@@ -57,11 +117,14 @@ export function useBranches() {
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
trackingRemote,
|
||||
getTrackingRemote,
|
||||
isLoadingBranches,
|
||||
branchFilter,
|
||||
setBranchFilter,
|
||||
resetBranchFilter,
|
||||
fetchBranches,
|
||||
pruneStaleEntries,
|
||||
gitRepoStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,11 +96,14 @@ export function WorktreePanel({
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
trackingRemote,
|
||||
getTrackingRemote,
|
||||
isLoadingBranches,
|
||||
branchFilter,
|
||||
setBranchFilter,
|
||||
resetBranchFilter,
|
||||
fetchBranches,
|
||||
pruneStaleEntries,
|
||||
gitRepoStatus,
|
||||
} = useBranches();
|
||||
|
||||
@@ -410,7 +413,7 @@ export function WorktreePanel({
|
||||
const [pushToRemoteDialogOpen, setPushToRemoteDialogOpen] = useState(false);
|
||||
const [pushToRemoteWorktree, setPushToRemoteWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Merge branch dialog state
|
||||
// Integrate branch dialog state
|
||||
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
|
||||
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
@@ -434,6 +437,11 @@ export function WorktreePanel({
|
||||
const [pullDialogWorktree, setPullDialogWorktree] = useState<WorktreeInfo | null>(null);
|
||||
const [pullDialogRemote, setPullDialogRemote] = useState<string | undefined>(undefined);
|
||||
|
||||
// Remotes cache: maps worktree path -> list of remotes (fetched when dropdown opens)
|
||||
const [remotesCache, setRemotesCache] = useState<
|
||||
Record<string, Array<{ name: string; url: string }>>
|
||||
>({});
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Periodic interval check (30 seconds) to detect branch changes on disk
|
||||
@@ -451,6 +459,21 @@ export function WorktreePanel({
|
||||
};
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
// Prune stale tracking-remote cache entries and remotes cache when worktrees change
|
||||
useEffect(() => {
|
||||
const activePaths = new Set(worktrees.map((w) => w.path));
|
||||
pruneStaleEntries(activePaths);
|
||||
setRemotesCache((prev) => {
|
||||
const next: typeof prev = {};
|
||||
for (const key of Object.keys(prev)) {
|
||||
if (activePaths.has(key)) {
|
||||
next[key] = prev[key];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [worktrees, pruneStaleEntries]);
|
||||
|
||||
const isWorktreeSelected = (worktree: WorktreeInfo) => {
|
||||
return worktree.isMain
|
||||
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
|
||||
@@ -467,6 +490,23 @@ export function WorktreePanel({
|
||||
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
// Fetch remotes for the submenu when the dropdown opens, but only if not already cached
|
||||
if (!remotesCache[worktree.path]) {
|
||||
const api = getHttpApiClient();
|
||||
api.worktree
|
||||
.listRemotes(worktree.path)
|
||||
.then((result) => {
|
||||
if (result.success && result.result) {
|
||||
setRemotesCache((prev) => ({
|
||||
...prev,
|
||||
[worktree.path]: result.result!.remotes.map((r) => ({ name: r.name, url: r.url })),
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Failed to fetch remotes for worktree:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -606,10 +646,15 @@ export function WorktreePanel({
|
||||
setPushToRemoteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle pull completed - refresh worktrees
|
||||
// Handle pull completed - refresh branches and worktrees
|
||||
const handlePullCompleted = useCallback(() => {
|
||||
// Refresh branch data (ahead/behind counts, tracking) and worktree list
|
||||
// after GitPullDialog completes the pull operation
|
||||
if (pullDialogWorktree) {
|
||||
fetchBranches(pullDialogWorktree.path);
|
||||
}
|
||||
fetchWorktrees({ silent: true });
|
||||
}, [fetchWorktrees]);
|
||||
}, [fetchWorktrees, fetchBranches, pullDialogWorktree]);
|
||||
|
||||
// Handle pull with remote selection when multiple remotes exist
|
||||
// Now opens the pull dialog which handles stash management and conflict resolution
|
||||
@@ -675,18 +720,37 @@ export function WorktreePanel({
|
||||
const handleConfirmSelectRemote = useCallback(
|
||||
async (worktree: WorktreeInfo, remote: string) => {
|
||||
if (selectRemoteOperation === 'pull') {
|
||||
// Open the pull dialog with the selected remote
|
||||
// Open the pull dialog — let GitPullDialog manage the pull operation
|
||||
// via its useEffect and onPulled callback (handlePullCompleted)
|
||||
setPullDialogRemote(remote);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
await _handlePull(worktree, remote);
|
||||
} else {
|
||||
await handlePush(worktree, remote);
|
||||
fetchBranches(worktree.path);
|
||||
fetchWorktrees({ silent: true });
|
||||
}
|
||||
fetchBranches(worktree.path);
|
||||
fetchWorktrees();
|
||||
},
|
||||
[selectRemoteOperation, _handlePull, handlePush, fetchBranches, fetchWorktrees]
|
||||
[selectRemoteOperation, handlePush, fetchBranches, fetchWorktrees]
|
||||
);
|
||||
|
||||
// Handle pull with a specific remote selected from the submenu (bypasses the remote selection dialog)
|
||||
const handlePullWithSpecificRemote = useCallback((worktree: WorktreeInfo, remote: string) => {
|
||||
// Open the pull dialog — let GitPullDialog manage the pull operation
|
||||
// via its useEffect and onPulled callback (handlePullCompleted)
|
||||
setPullDialogRemote(remote);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle push to a specific remote selected from the submenu (bypasses the remote selection dialog)
|
||||
const handlePushWithSpecificRemote = useCallback(
|
||||
async (worktree: WorktreeInfo, remote: string) => {
|
||||
await handlePush(worktree, remote);
|
||||
fetchBranches(worktree.path);
|
||||
fetchWorktrees({ silent: true });
|
||||
},
|
||||
[handlePush, fetchBranches, fetchWorktrees]
|
||||
);
|
||||
|
||||
// Handle confirming the push to remote dialog
|
||||
@@ -719,13 +783,13 @@ export function WorktreePanel({
|
||||
setMergeDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle merge completion - refresh worktrees and reassign features if branch was deleted
|
||||
const handleMerged = useCallback(
|
||||
(mergedWorktree: WorktreeInfo, deletedBranch: boolean) => {
|
||||
// Handle integration completion - refresh worktrees and reassign features if branch was deleted
|
||||
const handleIntegrated = useCallback(
|
||||
(integratedWorktree: WorktreeInfo, deletedBranch: boolean) => {
|
||||
fetchWorktrees();
|
||||
// If the branch was deleted, notify parent to reassign features to main
|
||||
if (deletedBranch && onBranchDeletedDuringMerge) {
|
||||
onBranchDeletedDuringMerge(mergedWorktree.branch);
|
||||
onBranchDeletedDuringMerge(integratedWorktree.branch);
|
||||
}
|
||||
},
|
||||
[fetchWorktrees, onBranchDeletedDuringMerge]
|
||||
@@ -777,6 +841,7 @@ export function WorktreePanel({
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
trackingRemote={getTrackingRemote(selectedWorktree.path)}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
@@ -789,10 +854,13 @@ export function WorktreePanel({
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
||||
remotes={remotesCache[selectedWorktree.path]}
|
||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||
onPull={handlePullWithRemoteSelection}
|
||||
onPush={handlePushWithRemoteSelection}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onPullWithRemote={handlePullWithSpecificRemote}
|
||||
onPushWithRemote={handlePushWithSpecificRemote}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
@@ -952,13 +1020,13 @@ export function WorktreePanel({
|
||||
onConfirm={handleConfirmSelectRemote}
|
||||
/>
|
||||
|
||||
{/* Merge Branch Dialog */}
|
||||
{/* Integrate Branch Dialog */}
|
||||
<MergeWorktreeDialog
|
||||
open={mergeDialogOpen}
|
||||
onOpenChange={setMergeDialogOpen}
|
||||
projectPath={projectPath}
|
||||
worktree={mergeWorktree}
|
||||
onMerged={handleMerged}
|
||||
onIntegrated={handleIntegrated}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
@@ -1019,6 +1087,8 @@ export function WorktreePanel({
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
trackingRemote={trackingRemote}
|
||||
getTrackingRemote={getTrackingRemote}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
@@ -1027,6 +1097,9 @@ export function WorktreePanel({
|
||||
onPull={handlePullWithRemoteSelection}
|
||||
onPush={handlePushWithRemoteSelection}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onPullWithRemote={handlePullWithSpecificRemote}
|
||||
onPushWithRemote={handlePushWithSpecificRemote}
|
||||
remotesCache={remotesCache}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
@@ -1112,6 +1185,7 @@ export function WorktreePanel({
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
trackingRemote={getTrackingRemote(mainWorktree.path)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
@@ -1126,6 +1200,9 @@ export function WorktreePanel({
|
||||
onPull={handlePullWithRemoteSelection}
|
||||
onPush={handlePushWithRemoteSelection}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onPullWithRemote={handlePullWithSpecificRemote}
|
||||
onPushWithRemote={handlePushWithSpecificRemote}
|
||||
remotes={remotesCache[mainWorktree.path]}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
@@ -1191,6 +1268,7 @@ export function WorktreePanel({
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
trackingRemote={getTrackingRemote(worktree.path)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
@@ -1205,6 +1283,9 @@ export function WorktreePanel({
|
||||
onPull={handlePullWithRemoteSelection}
|
||||
onPush={handlePushWithRemoteSelection}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onPullWithRemote={handlePullWithSpecificRemote}
|
||||
onPushWithRemote={handlePushWithSpecificRemote}
|
||||
remotes={remotesCache[worktree.path]}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
@@ -1317,13 +1398,13 @@ export function WorktreePanel({
|
||||
onConfirm={handleConfirmSelectRemote}
|
||||
/>
|
||||
|
||||
{/* Merge Branch Dialog */}
|
||||
{/* Integrate Branch Dialog */}
|
||||
<MergeWorktreeDialog
|
||||
open={mergeDialogOpen}
|
||||
onOpenChange={setMergeDialogOpen}
|
||||
projectPath={projectPath}
|
||||
worktree={mergeWorktree}
|
||||
onMerged={handleMerged}
|
||||
onIntegrated={handleIntegrated}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
|
||||
@@ -42,8 +42,6 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('ContextView');
|
||||
import { sanitizeFilename } from '@/lib/image-utils';
|
||||
import { Markdown } from '../ui/markdown';
|
||||
import {
|
||||
@@ -54,6 +52,8 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
const logger = createLogger('ContextView');
|
||||
|
||||
interface ContextFile {
|
||||
name: string;
|
||||
type: 'text' | 'image';
|
||||
@@ -973,7 +973,7 @@ export function ContextView() {
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden px-4 pb-4">
|
||||
<div className="flex-1 overflow-hidden px-4 pb-2 sm:pb-4">
|
||||
{selectedFile.type === 'image' ? (
|
||||
<div
|
||||
className="h-full flex items-center justify-center bg-card rounded-lg"
|
||||
|
||||
@@ -313,24 +313,69 @@ export function GraphViewPage() {
|
||||
// Handle add and start feature
|
||||
const handleAddAndStartFeature = useCallback(
|
||||
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
|
||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||
try {
|
||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||
await handleAddFeature(featureData);
|
||||
|
||||
const latestFeatures = useAppStore.getState().features;
|
||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
||||
|
||||
if (newFeature) {
|
||||
await handleStartImplementation(newFeature);
|
||||
}
|
||||
// Create feature directly with in_progress status to avoid brief backlog flash
|
||||
await handleAddFeature({ ...featureData, initialStatus: 'in_progress' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to add and start feature:', error);
|
||||
logger.error('Failed to create feature:', error);
|
||||
toast.error(
|
||||
`Failed to add and start feature: ${error instanceof Error ? error.message : String(error)}`
|
||||
`Failed to create feature: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestFeatures = useAppStore.getState().features;
|
||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
||||
|
||||
if (newFeature) {
|
||||
try {
|
||||
await handleStartImplementation(newFeature);
|
||||
} catch (startError) {
|
||||
logger.error('Failed to start implementation, rolling back feature status:', startError);
|
||||
// Rollback: revert the newly created feature back to backlog so it isn't stuck in in_progress
|
||||
try {
|
||||
const { updateFeature } = useAppStore.getState();
|
||||
updateFeature(newFeature.id, { status: 'backlog' });
|
||||
// Also persist the rollback so it survives page refresh
|
||||
await persistFeatureUpdate(newFeature.id, { status: 'backlog' });
|
||||
logger.info(`Rolled back feature ${newFeature.id} status to backlog`);
|
||||
} catch (rollbackErr) {
|
||||
logger.error('Failed to rollback feature status:', rollbackErr);
|
||||
}
|
||||
toast.error(
|
||||
`Failed to start feature: ${startError instanceof Error ? startError.message : String(startError)}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Feature was not found in the store after creation — it may have been
|
||||
// persisted but not yet visible in the snapshot. Attempt to locate it
|
||||
// and roll it back so it doesn't remain stuck in 'in_progress'.
|
||||
logger.error(
|
||||
'Newly created feature not found in store after handleAddFeature completed. ' +
|
||||
`Store has ${latestFeatures.length} features, expected a new entry.`
|
||||
);
|
||||
// Best-effort: re-read the store to find any feature still in 'in_progress'
|
||||
// that wasn't in the original set. We must use a fresh snapshot here because
|
||||
// latestFeatures was captured before the async gap and may not contain the new entry.
|
||||
const freshFeatures = useAppStore.getState().features;
|
||||
const stuckFeature = freshFeatures.find(
|
||||
(f) => f.status === 'in_progress' && !featuresBeforeIds.has(f.id)
|
||||
);
|
||||
if (stuckFeature) {
|
||||
try {
|
||||
const { updateFeature } = useAppStore.getState();
|
||||
updateFeature(stuckFeature.id, { status: 'backlog' });
|
||||
await persistFeatureUpdate(stuckFeature.id, { status: 'backlog' });
|
||||
logger.info(`Rolled back orphaned feature ${stuckFeature.id} status to backlog`);
|
||||
} catch (rollbackErr) {
|
||||
logger.error('Failed to rollback orphaned feature status:', rollbackErr);
|
||||
}
|
||||
}
|
||||
toast.error('Feature was created but could not be started. Please try again.');
|
||||
}
|
||||
},
|
||||
[handleAddFeature, handleStartImplementation]
|
||||
[handleAddFeature, handleStartImplementation, persistFeatureUpdate]
|
||||
);
|
||||
|
||||
if (!currentProject) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Workflow,
|
||||
Database,
|
||||
Terminal,
|
||||
ScrollText,
|
||||
} from 'lucide-react';
|
||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||
|
||||
@@ -20,6 +21,7 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||
{ id: 'identity', label: 'Identity', icon: User },
|
||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||
{ id: 'commands', label: 'Commands', icon: Terminal },
|
||||
{ id: 'scripts', label: 'Terminal Scripts', icon: ScrollText },
|
||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||
{ id: 'data', label: 'Data', icon: Database },
|
||||
|
||||
@@ -5,6 +5,7 @@ export type ProjectSettingsViewId =
|
||||
| 'theme'
|
||||
| 'worktrees'
|
||||
| 'commands'
|
||||
| 'scripts'
|
||||
| 'claude'
|
||||
| 'data'
|
||||
| 'danger';
|
||||
|
||||
@@ -3,5 +3,6 @@ export { ProjectIdentitySection } from './project-identity-section';
|
||||
export { ProjectThemeSection } from './project-theme-section';
|
||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
export { CommandsSection } from './commands-section';
|
||||
export { TerminalScriptsSection } from './terminal-scripts-section';
|
||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ProjectIdentitySection } from './project-identity-section';
|
||||
import { ProjectThemeSection } from './project-theme-section';
|
||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
import { CommandsSection } from './commands-section';
|
||||
import { TerminalScriptsSection } from './terminal-scripts-section';
|
||||
import { ProjectModelsSection } from './project-models-section';
|
||||
import { DataManagementSection } from './data-management-section';
|
||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||
@@ -91,6 +92,8 @@ export function ProjectSettingsView() {
|
||||
return <WorktreePreferencesSection project={currentProject} />;
|
||||
case 'commands':
|
||||
return <CommandsSection project={currentProject} />;
|
||||
case 'scripts':
|
||||
return <TerminalScriptsSection project={currentProject} />;
|
||||
case 'claude':
|
||||
return <ProjectModelsSection project={currentProject} />;
|
||||
case 'data':
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Shared terminal script constants used by both the settings section
|
||||
* (terminal-scripts-section.tsx) and the terminal header dropdown
|
||||
* (terminal-scripts-dropdown.tsx).
|
||||
*
|
||||
* Centralising the default scripts here ensures both components show
|
||||
* the same fallback list and removes the duplicated definition.
|
||||
*/
|
||||
|
||||
export interface TerminalScript {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
/** Default scripts shown when the user has not configured any custom scripts yet. */
|
||||
export const DEFAULT_TERMINAL_SCRIPTS: TerminalScript[] = [
|
||||
{ id: 'default-dev', name: 'Dev Server', command: 'npm run dev' },
|
||||
{ id: 'default-format', name: 'Format', command: 'npm run format' },
|
||||
{ id: 'default-test', name: 'Test', command: 'npm run test' },
|
||||
{ id: 'default-lint', name: 'Lint', command: 'npm run lint' },
|
||||
];
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useState, useEffect, useCallback, type KeyboardEvent } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollText, Save, RotateCcw, Info, Plus, GripVertical, Trash2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useProjectSettings } from '@/hooks/queries';
|
||||
import { useUpdateProjectSettings } from '@/hooks/mutations';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants';
|
||||
|
||||
/** Preset scripts for quick addition */
|
||||
const SCRIPT_PRESETS = [
|
||||
{ name: 'Dev Server', command: 'npm run dev' },
|
||||
{ name: 'Build', command: 'npm run build' },
|
||||
{ name: 'Test', command: 'npm run test' },
|
||||
{ name: 'Lint', command: 'npm run lint' },
|
||||
{ name: 'Format', command: 'npm run format' },
|
||||
{ name: 'Type Check', command: 'npm run typecheck' },
|
||||
{ name: 'Start', command: 'npm start' },
|
||||
{ name: 'Clean', command: 'npm run clean' },
|
||||
] as const;
|
||||
|
||||
interface ScriptEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
interface TerminalScriptsSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
/** Generate a unique ID for a new script */
|
||||
function generateId(): string {
|
||||
return `script-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export function TerminalScriptsSection({ project }: TerminalScriptsSectionProps) {
|
||||
// Fetch project settings using TanStack Query
|
||||
const { data: projectSettings, isLoading, isError } = useProjectSettings(project.path);
|
||||
|
||||
// Mutation hook for updating project settings
|
||||
const updateSettingsMutation = useUpdateProjectSettings(project.path);
|
||||
|
||||
// Local state for scripts
|
||||
const [scripts, setScripts] = useState<ScriptEntry[]>([]);
|
||||
const [originalScripts, setOriginalScripts] = useState<ScriptEntry[]>([]);
|
||||
|
||||
// Dragging state
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
// Reset local state when project changes
|
||||
useEffect(() => {
|
||||
setScripts([]);
|
||||
setOriginalScripts([]);
|
||||
}, [project.path]);
|
||||
|
||||
// Sync local state when project settings load or project path changes.
|
||||
// Including project.path ensures originalScripts is re-populated after a
|
||||
// project switch even if projectSettings is cached from a previous render.
|
||||
useEffect(() => {
|
||||
if (projectSettings) {
|
||||
const configured = projectSettings.terminalScripts;
|
||||
const scriptList =
|
||||
configured && configured.length > 0
|
||||
? configured.map((s) => ({ id: s.id, name: s.name, command: s.command }))
|
||||
: DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s }));
|
||||
setScripts(scriptList);
|
||||
setOriginalScripts(JSON.parse(JSON.stringify(scriptList)));
|
||||
}
|
||||
}, [projectSettings, project.path]);
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasChanges = JSON.stringify(scripts) !== JSON.stringify(originalScripts);
|
||||
const isSaving = updateSettingsMutation.isPending;
|
||||
|
||||
// Save scripts
|
||||
const handleSave = useCallback(() => {
|
||||
// Filter out scripts with empty names or commands
|
||||
const validScripts = scripts.filter((s) => s.name.trim() && s.command.trim());
|
||||
const normalizedScripts = validScripts.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name.trim(),
|
||||
command: s.command.trim(),
|
||||
}));
|
||||
|
||||
updateSettingsMutation.mutate(
|
||||
{ terminalScripts: normalizedScripts },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setScripts(normalizedScripts);
|
||||
setOriginalScripts(JSON.parse(JSON.stringify(normalizedScripts)));
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [scripts, updateSettingsMutation]);
|
||||
|
||||
// Reset to original values
|
||||
const handleReset = useCallback(() => {
|
||||
setScripts(JSON.parse(JSON.stringify(originalScripts)));
|
||||
}, [originalScripts]);
|
||||
|
||||
// Add a new empty script entry
|
||||
const handleAddScript = useCallback(() => {
|
||||
setScripts((prev) => [...prev, { id: generateId(), name: '', command: '' }]);
|
||||
}, []);
|
||||
|
||||
// Add a preset script
|
||||
const handleAddPreset = useCallback((preset: { name: string; command: string }) => {
|
||||
setScripts((prev) => [
|
||||
...prev,
|
||||
{ id: generateId(), name: preset.name, command: preset.command },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
// Remove a script by index
|
||||
const handleRemoveScript = useCallback((index: number) => {
|
||||
setScripts((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
// Update a script field
|
||||
const handleUpdateScript = useCallback(
|
||||
(index: number, field: 'name' | 'command', value: string) => {
|
||||
setScripts((prev) => prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts (Enter to save)
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && hasChanges && !isSaving) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[hasChanges, isSaving, handleSave]
|
||||
);
|
||||
|
||||
// Drag and drop handlers for reordering
|
||||
const handleDragStart = useCallback((index: number) => {
|
||||
setDraggedIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
setDragOverIndex(index);
|
||||
},
|
||||
[draggedIndex]
|
||||
);
|
||||
|
||||
// Accept the drop so the browser sets dropEffect correctly (prevents 'none')
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex !== null && dragOverIndex !== null && draggedIndex !== dragOverIndex) {
|
||||
setScripts((prev) => {
|
||||
const newScripts = [...prev];
|
||||
const [removed] = newScripts.splice(draggedIndex, 1);
|
||||
newScripts.splice(dragOverIndex, 0, removed);
|
||||
return newScripts;
|
||||
});
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
},
|
||||
[draggedIndex, dragOverIndex]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback((_e: React.DragEvent) => {
|
||||
// The reorder is already performed in handleDrop. This handler only
|
||||
// needs to reset the drag state (e.g. when the drop was cancelled by
|
||||
// releasing outside a valid target or pressing Escape).
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<ScrollText className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Terminal Quick Scripts
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure quick-access scripts that appear in the terminal header dropdown. Click any
|
||||
script to run it instantly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-destructive">
|
||||
Failed to load project settings. Please try again.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Scripts List */}
|
||||
<div className="space-y-2">
|
||||
{scripts.map((script, index) => (
|
||||
<div
|
||||
key={script.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2 rounded-lg border border-border/30 bg-accent/10 transition-all',
|
||||
draggedIndex === index && 'opacity-50',
|
||||
dragOverIndex === index && 'border-brand-500/50 bg-brand-500/5'
|
||||
)}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e)}
|
||||
onDragEnd={(e) => handleDragEnd(e)}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0 p-0.5"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Script name */}
|
||||
<Input
|
||||
value={script.name}
|
||||
onChange={(e) => handleUpdateScript(index, 'name', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Script name"
|
||||
className="h-8 text-sm flex-[0.4] min-w-0"
|
||||
/>
|
||||
|
||||
{/* Script command */}
|
||||
<Input
|
||||
value={script.command}
|
||||
onChange={(e) => handleUpdateScript(index, 'command', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Command to run"
|
||||
className="h-8 text-sm font-mono flex-[0.6] min-w-0"
|
||||
/>
|
||||
|
||||
{/* Remove button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveScript(index)}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive shrink-0"
|
||||
aria-label={`Remove ${script.name || 'script'}`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{scripts.length === 0 && (
|
||||
<div className="text-center py-6 text-sm text-muted-foreground">
|
||||
No scripts configured. Add some below or use a preset.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Script Button */}
|
||||
<Button variant="outline" size="sm" onClick={handleAddScript} className="gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Script
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Presets */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-foreground">Quick Add Presets</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SCRIPT_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.command}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAddPreset(preset)}
|
||||
className="text-xs font-mono h-7 px-2"
|
||||
>
|
||||
{preset.command}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
|
||||
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Terminal Quick Scripts</p>
|
||||
<p>
|
||||
These scripts appear in the terminal header as a dropdown menu (the{' '}
|
||||
<ScrollText className="inline-block w-3 h-3 mx-0.5 align-middle" /> icon).
|
||||
Clicking a script will type the command into the active terminal and press Enter.
|
||||
Drag to reorder scripts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
applyStickyModifier,
|
||||
type StickyModifier,
|
||||
} from './sticky-modifier-keys';
|
||||
import { TerminalScriptsDropdown } from './terminal-scripts-dropdown';
|
||||
|
||||
const logger = createLogger('Terminal');
|
||||
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||
@@ -156,6 +157,11 @@ export function TerminalPanel({
|
||||
const [isImageDragOver, setIsImageDragOver] = useState(false);
|
||||
const [isProcessingImage, setIsProcessingImage] = useState(false);
|
||||
const hasRunInitialCommandRef = useRef(false);
|
||||
// Tracks whether the connected shell is a Windows shell (PowerShell, cmd, etc.).
|
||||
// Maintained as a ref (not state) so sendCommand can read the current value without
|
||||
// causing unnecessary re-renders or stale closure issues. Set inside ws.onmessage
|
||||
// when the 'connected' message is received (see isWindowsShell detection below).
|
||||
const isWindowsShellRef = useRef(false);
|
||||
const searchAddonRef = useRef<XSearchAddon | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
@@ -376,6 +382,17 @@ export function TerminalPanel({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Send a command to the terminal (types the command and presses Enter).
|
||||
// Uses isWindowsShellRef.current to pick the correct line ending:
|
||||
// Windows shells (PowerShell, cmd) expect '\r\n'; Unix/macOS shells expect '\n'.
|
||||
// isWindowsShellRef is set in ws.onmessage when the 'connected' message arrives.
|
||||
const sendCommand = useCallback((command: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const lineEnding = isWindowsShellRef.current ? '\r\n' : '\n';
|
||||
wsRef.current.send(JSON.stringify({ type: 'input', data: command + lineEnding }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Paste from clipboard
|
||||
const pasteFromClipboard = useCallback(async () => {
|
||||
const terminal = xtermRef.current;
|
||||
@@ -1090,6 +1107,9 @@ export function TerminalPanel({
|
||||
shellPath.includes('powershell') ||
|
||||
shellPath.includes('pwsh') ||
|
||||
shellPath.includes('cmd.exe');
|
||||
// Keep the component-level ref in sync so sendCommand and
|
||||
// runCommandOnConnect both use the correct line ending ('\r\n' vs '\n').
|
||||
isWindowsShellRef.current = isWindowsShell;
|
||||
const isPowerShell = shellPath.includes('powershell') || shellPath.includes('pwsh');
|
||||
|
||||
if (msg.shell) {
|
||||
@@ -1903,6 +1923,12 @@ export function TerminalPanel({
|
||||
<ZoomIn className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{/* Quick scripts dropdown */}
|
||||
<TerminalScriptsDropdown
|
||||
onRunCommand={sendCommand}
|
||||
isConnected={connectionStatus === 'connected'}
|
||||
/>
|
||||
|
||||
{/* Settings popover */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { ScrollText, Play, Settings2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useProjectSettings } from '@/hooks/queries';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DEFAULT_TERMINAL_SCRIPTS } from '../project-settings-view/terminal-scripts-constants';
|
||||
|
||||
interface TerminalScriptsDropdownProps {
|
||||
/** Callback to send a command + newline to the terminal */
|
||||
onRunCommand: (command: string) => void;
|
||||
/** Whether the terminal is connected and ready */
|
||||
isConnected: boolean;
|
||||
/** Optional callback to navigate to project settings scripts section */
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown menu in the terminal header bar that provides quick-access
|
||||
* to user-configured project scripts. Clicking a script inserts the
|
||||
* command into the terminal and presses Enter.
|
||||
*/
|
||||
export function TerminalScriptsDropdown({
|
||||
onRunCommand,
|
||||
isConnected,
|
||||
onOpenSettings,
|
||||
}: TerminalScriptsDropdownProps) {
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
const { data: projectSettings } = useProjectSettings(currentProject?.path);
|
||||
|
||||
// Use project-configured scripts or fall back to defaults
|
||||
const scripts = useMemo(() => {
|
||||
const configured = projectSettings?.terminalScripts;
|
||||
if (configured && configured.length > 0) {
|
||||
return configured;
|
||||
}
|
||||
return DEFAULT_TERMINAL_SCRIPTS;
|
||||
}, [projectSettings?.terminalScripts]);
|
||||
|
||||
const handleRunScript = useCallback(
|
||||
(command: string) => {
|
||||
if (!isConnected) return;
|
||||
onRunCommand(command);
|
||||
},
|
||||
[isConnected, onRunCommand]
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Quick Scripts"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
<ScrollText className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
className="w-56"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
|
||||
Quick Scripts
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{scripts.map((script) => (
|
||||
<DropdownMenuItem
|
||||
key={script.id}
|
||||
onClick={() => handleRunScript(script.command)}
|
||||
disabled={!isConnected}
|
||||
className="gap-2"
|
||||
>
|
||||
<Play className={cn('h-3.5 w-3.5 shrink-0 text-brand-500')} />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm truncate">{script.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono truncate">
|
||||
{script.command}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{onOpenSettings && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onOpenSettings} className="gap-2 text-muted-foreground">
|
||||
<Settings2 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-sm">Configure Scripts...</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user