Initial public release
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { DirectoryTree } from './DirectoryTree';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||
import { cn, formatPathForDisplay } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { RiCheckboxBlankLine, RiCheckboxLine } from '@remixicon/react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
|
||||
const SHOW_HIDDEN_STORAGE_KEY = 'directoryTreeShowHidden';
|
||||
|
||||
interface DirectoryExplorerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const { currentDirectory, homeDirectory, setDirectory, isHomeReady } = useDirectoryStore();
|
||||
const [pendingPath, setPendingPath] = React.useState<string | null>(null);
|
||||
const [hasUserSelection, setHasUserSelection] = React.useState(false);
|
||||
const [isConfirming, setIsConfirming] = React.useState(false);
|
||||
const [showHidden, setShowHidden] = React.useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SHOW_HIDDEN_STORAGE_KEY);
|
||||
if (stored === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (stored === 'false') {
|
||||
return false;
|
||||
}
|
||||
} catch { /* ignored */ }
|
||||
return false;
|
||||
});
|
||||
const { isDesktop, requestAccess, startAccessing } = useFileSystemAccess();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setPendingPath(null);
|
||||
setHasUserSelection(false);
|
||||
setIsConfirming(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
if (!hasUserSelection && !pendingPath && homeDirectory && isHomeReady) {
|
||||
setPendingPath(homeDirectory);
|
||||
setHasUserSelection(true);
|
||||
}
|
||||
}, [open, hasUserSelection, pendingPath, homeDirectory, isHomeReady]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(SHOW_HIDDEN_STORAGE_KEY, showHidden ? 'true' : 'false');
|
||||
} catch { /* ignored */ }
|
||||
}, [showHidden]);
|
||||
|
||||
const formattedPendingPath = React.useMemo(() => {
|
||||
if (!pendingPath) {
|
||||
return 'Select a directory';
|
||||
}
|
||||
return formatPathForDisplay(pendingPath, homeDirectory);
|
||||
}, [pendingPath, homeDirectory]);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const finalizeSelection = React.useCallback(async (targetPath: string) => {
|
||||
if (!targetPath || isConfirming) {
|
||||
return;
|
||||
}
|
||||
if (targetPath === currentDirectory) {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
setIsConfirming(true);
|
||||
try {
|
||||
let resolvedPath = targetPath;
|
||||
|
||||
if (isDesktop) {
|
||||
const accessResult = await requestAccess(targetPath);
|
||||
if (!accessResult.success) {
|
||||
toast.error('Unable to access directory', {
|
||||
description: accessResult.error || 'Desktop denied directory access.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolvedPath = accessResult.path ?? targetPath;
|
||||
|
||||
const startResult = await startAccessing(resolvedPath);
|
||||
if (!startResult.success) {
|
||||
toast.error('Failed to open directory', {
|
||||
description: startResult.error || 'Desktop could not grant file access.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDirectory(resolvedPath);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
toast.error('Failed to select directory', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error occurred.',
|
||||
});
|
||||
} finally {
|
||||
setIsConfirming(false);
|
||||
}
|
||||
}, [
|
||||
currentDirectory,
|
||||
handleClose,
|
||||
isDesktop,
|
||||
requestAccess,
|
||||
setDirectory,
|
||||
startAccessing,
|
||||
isConfirming,
|
||||
]);
|
||||
|
||||
const handleConfirm = React.useCallback(async () => {
|
||||
if (!pendingPath) {
|
||||
return;
|
||||
}
|
||||
await finalizeSelection(pendingPath);
|
||||
}, [finalizeSelection, pendingPath]);
|
||||
|
||||
const handleSelectPath = React.useCallback((path: string) => {
|
||||
setPendingPath(path);
|
||||
setHasUserSelection(true);
|
||||
}, []);
|
||||
|
||||
const handleDoubleClickPath = React.useCallback(async (path: string) => {
|
||||
setPendingPath(path);
|
||||
setHasUserSelection(true);
|
||||
await finalizeSelection(path);
|
||||
}, [
|
||||
finalizeSelection,
|
||||
]);
|
||||
|
||||
const dialogHeader = (
|
||||
<DialogHeader className="flex-shrink-0 px-4 pb-3 pt-[calc(var(--oc-safe-area-top,0px)+0.5rem)] sm:px-0 sm:pb-4 sm:pt-[calc(var(--oc-safe-area-top,0px)+0px)]">
|
||||
<DialogTitle>Select project directory</DialogTitle>
|
||||
<DialogDescription className="hidden sm:block">
|
||||
Choose the working directory used for sessions, commands, and OpenCode operations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
);
|
||||
|
||||
const scrollContent = (
|
||||
<ScrollableOverlay
|
||||
outerClassName="flex-1 min-h-0 overflow-hidden"
|
||||
className="directory-dialog-body px-2.5 pb-2.5 sm:px-0 sm:pb-0"
|
||||
>
|
||||
<div className="rounded-xl border border-border/40 bg-sidebar/60 px-3 py-2 sm:px-4 sm:py-3">
|
||||
<span className="typography-micro text-muted-foreground">Currently selected</span>
|
||||
<div
|
||||
className="typography-ui-label font-medium text-foreground truncate"
|
||||
title={formatPathForDisplay(currentDirectory, homeDirectory)}
|
||||
>
|
||||
{formatPathForDisplay(currentDirectory, homeDirectory)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="directory-grid mt-2 grid gap-2 grid-cols-1 sm:mt-4 sm:gap-4 sm:grid-cols-[minmax(260px,340px)_minmax(0,1fr)]">
|
||||
<div className="rounded-xl border border-border/40 bg-sidebar/70 p-1.5 sm:p-2 sm:h-auto">
|
||||
<DirectoryTree
|
||||
variant="inline"
|
||||
currentPath={pendingPath ?? currentDirectory}
|
||||
onSelectPath={handleSelectPath}
|
||||
onDoubleClickPath={handleDoubleClickPath}
|
||||
className="min-h-[280px] h-[55vh] sm:h-[440px]"
|
||||
selectionBehavior="deferred"
|
||||
showHidden={showHidden}
|
||||
rootDirectory={isHomeReady ? homeDirectory : null}
|
||||
isRootReady={isHomeReady}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5 sm:gap-3">
|
||||
<div className="rounded-xl border border-border/40 bg-sidebar/60 px-3 py-2 sm:px-4 sm:py-3">
|
||||
<span className="typography-micro text-muted-foreground">Selected directory</span>
|
||||
<div
|
||||
className="typography-ui-label font-medium text-foreground truncate"
|
||||
title={pendingPath ? formattedPendingPath : undefined}
|
||||
>
|
||||
{formattedPendingPath}
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
pressed={showHidden}
|
||||
onPressedChange={(value) => setShowHidden(Boolean(value))}
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2 rounded-xl border-border/40 bg-sidebar/60 px-3 py-2 text-foreground min-w-0 h-auto sm:px-4 sm:py-3"
|
||||
>
|
||||
{showHidden ? (
|
||||
<RiCheckboxLine className="h-4 w-4" />
|
||||
) : (
|
||||
<RiCheckboxBlankLine className="h-4 w-4" />
|
||||
)}
|
||||
Show hidden directories
|
||||
</Toggle>
|
||||
<div className="hidden rounded-xl border border-dashed border-border/40 bg-sidebar/40 px-3 py-2 sm:block sm:px-4 sm:py-3">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Use the tree to browse, pin frequently used locations, or create a new directory.
|
||||
Select a folder, then confirm to update the working directory for OpenChamber.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
|
||||
const renderActionButtons = () => (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isConfirming}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={isConfirming || !hasUserSelection || !pendingPath}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isConfirming ? 'Applying...' : 'Use Selected Directory'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title="Select project directory"
|
||||
className="max-w-full"
|
||||
footer={<div className="flex flex-col gap-2">{renderActionButtons()}</div>}
|
||||
>
|
||||
{scrollContent}
|
||||
</MobileOverlayPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'flex w-full max-w-[min(640px,100vw)] max-h-[calc(100vh-32px)] flex-col gap-0 overflow-hidden p-0 sm:max-h-[80vh] sm:max-w-4xl sm:p-6'
|
||||
)}
|
||||
>
|
||||
{dialogHeader}
|
||||
{scrollContent}
|
||||
<DialogFooter
|
||||
className="sticky bottom-0 flex w-full flex-shrink-0 flex-col gap-2 border-t border-border/40 bg-sidebar px-4 py-3 sm:static sm:flex-row sm:justify-end sm:gap-2 sm:border-0 sm:bg-transparent sm:px-0 sm:py-0"
|
||||
>
|
||||
{renderActionButtons()}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,858 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { RiCheckboxBlankLine, RiCheckboxLine, RiDeleteBinLine } from '@remixicon/react';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { DirectoryExplorerDialog } from './DirectoryExplorerDialog';
|
||||
import { cn, formatPathForDisplay } from '@/lib/utils';
|
||||
import type { Session } from '@opencode-ai/sdk';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import {
|
||||
createWorktree,
|
||||
getWorktreeStatus,
|
||||
listWorktrees as listGitWorktrees,
|
||||
mapWorktreeToMetadata,
|
||||
removeWorktree,
|
||||
} from '@/lib/git/worktreeService';
|
||||
import { checkIsGitRepository, ensureOpenChamberIgnored, gitPush } from '@/lib/gitApi';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
|
||||
const WORKTREE_ROOT = '.openchamber';
|
||||
|
||||
const renderToastDescription = (text?: string) =>
|
||||
text ? <span className="text-foreground/80 dark:text-foreground/70">{text}</span> : undefined;
|
||||
|
||||
const sanitizeBranchNameInput = (value: string): string => {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^A-Za-z0-9._/-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/\/{2,}/g, '/')
|
||||
.replace(/\/-+/g, '/')
|
||||
.replace(/-+\//g, '/')
|
||||
.replace(/^[-/]+/, '')
|
||||
.replace(/[-/]+$/, '');
|
||||
};
|
||||
|
||||
const sanitizeWorktreeSlug = (value: string): string => {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/^[-_]+|[-_]+$/g, '')
|
||||
.slice(0, 120);
|
||||
};
|
||||
|
||||
const normalizeProjectDirectory = (path: string | null | undefined): string => {
|
||||
if (!path) {
|
||||
return '';
|
||||
}
|
||||
const replaced = path.replace(/\\/g, '/');
|
||||
if (replaced === '/') {
|
||||
return '/';
|
||||
}
|
||||
return replaced.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
const joinWorktreePath = (projectDirectory: string, slug: string): string => {
|
||||
const normalizedProject = normalizeProjectDirectory(projectDirectory);
|
||||
const cleanSlug = sanitizeWorktreeSlug(slug);
|
||||
const base =
|
||||
!normalizedProject || normalizedProject === '/'
|
||||
? `/${WORKTREE_ROOT}`
|
||||
: `${normalizedProject}/${WORKTREE_ROOT}`;
|
||||
return cleanSlug ? `${base}/${cleanSlug}` : base;
|
||||
};
|
||||
|
||||
type DeleteDialogState = {
|
||||
sessions: Session[];
|
||||
dateLabel?: string;
|
||||
mode: 'session' | 'worktree';
|
||||
worktree?: WorktreeMetadata | null;
|
||||
};
|
||||
|
||||
export const SessionDialogs: React.FC = () => {
|
||||
const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false);
|
||||
const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false);
|
||||
const [branchName, setBranchName] = React.useState<string>('');
|
||||
const [availableWorktrees, setAvailableWorktrees] = React.useState<WorktreeMetadata[]>([]);
|
||||
const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false);
|
||||
const [worktreeError, setWorktreeError] = React.useState<string | null>(null);
|
||||
const [isCheckingGitRepository, setIsCheckingGitRepository] = React.useState(false);
|
||||
const [isGitRepository, setIsGitRepository] = React.useState<boolean | null>(null);
|
||||
const [isCreatingWorktree, setIsCreatingWorktree] = React.useState(false);
|
||||
const ensuredIgnoreDirectories = React.useRef<Set<string>>(new Set());
|
||||
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
|
||||
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
|
||||
const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false);
|
||||
const [isProcessingDelete, setIsProcessingDelete] = React.useState(false);
|
||||
|
||||
const {
|
||||
sessions,
|
||||
createSession,
|
||||
deleteSession,
|
||||
deleteSessions,
|
||||
loadSessions,
|
||||
initializeNewOpenChamberSession,
|
||||
setWorktreeMetadata,
|
||||
setSessionDirectory,
|
||||
getWorktreeMetadata,
|
||||
isLoading,
|
||||
} = useSessionStore();
|
||||
const { currentDirectory, homeDirectory, hasPersistedDirectory, isHomeReady } = useDirectoryStore();
|
||||
const { agents } = useConfigStore();
|
||||
const { isSessionCreateDialogOpen, setSessionCreateDialogOpen } = useUIStore();
|
||||
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
|
||||
const useMobileOverlay = isMobile || isTablet || hasTouchInput;
|
||||
|
||||
const projectDirectory = React.useMemo(() => normalizeProjectDirectory(currentDirectory), [currentDirectory]);
|
||||
const sanitizedBranchName = React.useMemo(() => sanitizeBranchNameInput(branchName), [branchName]);
|
||||
const sanitizedWorktreeSlug = React.useMemo(() => sanitizeWorktreeSlug(sanitizedBranchName), [sanitizedBranchName]);
|
||||
const worktreePreviewPath = React.useMemo(() => {
|
||||
if (!projectDirectory || !sanitizedWorktreeSlug) {
|
||||
return '';
|
||||
}
|
||||
return joinWorktreePath(projectDirectory, sanitizedWorktreeSlug);
|
||||
}, [projectDirectory, sanitizedWorktreeSlug]);
|
||||
const isGitRepo = isGitRepository === true;
|
||||
const hasDirtyWorktrees = React.useMemo(
|
||||
() =>
|
||||
(deleteDialog?.worktree?.status?.isDirty ?? false) ||
|
||||
deleteDialogSummaries.some((entry) => entry.metadata.status?.isDirty),
|
||||
[deleteDialog?.worktree?.status?.isDirty, deleteDialogSummaries],
|
||||
);
|
||||
const canRemoveRemoteBranches = React.useMemo(
|
||||
() => {
|
||||
const targetWorktree = deleteDialog?.worktree;
|
||||
if (targetWorktree && typeof targetWorktree.branch === 'string' && targetWorktree.branch.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
deleteDialogSummaries.length > 0 &&
|
||||
deleteDialogSummaries.every(({ metadata }) => typeof metadata.branch === 'string' && metadata.branch.trim().length > 0)
|
||||
);
|
||||
},
|
||||
[deleteDialog?.worktree, deleteDialogSummaries],
|
||||
);
|
||||
const isWorktreeDelete = deleteDialog?.mode === 'worktree';
|
||||
const shouldArchiveWorktree = isWorktreeDelete;
|
||||
const removeRemoteOptionDisabled =
|
||||
isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!projectDirectory) {
|
||||
return;
|
||||
}
|
||||
if (ensuredIgnoreDirectories.current.has(projectDirectory)) {
|
||||
return;
|
||||
}
|
||||
ensureOpenChamberIgnored(projectDirectory)
|
||||
.then(() => ensuredIgnoreDirectories.current.add(projectDirectory))
|
||||
.catch((error) => {
|
||||
console.warn('Failed to ensure .openchamber directory is ignored:', error);
|
||||
ensuredIgnoreDirectories.current.delete(projectDirectory);
|
||||
});
|
||||
}, [projectDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions, currentDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasShownInitialDirectoryPrompt && isHomeReady && !hasPersistedDirectory) {
|
||||
setIsDirectoryDialogOpen(true);
|
||||
setHasShownInitialDirectoryPrompt(true);
|
||||
}
|
||||
}, [hasPersistedDirectory, hasShownInitialDirectoryPrompt, isHomeReady]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isSessionCreateDialogOpen) {
|
||||
setBranchName('');
|
||||
setAvailableWorktrees([]);
|
||||
setWorktreeError(null);
|
||||
setIsLoadingWorktrees(false);
|
||||
setIsCheckingGitRepository(false);
|
||||
setIsGitRepository(null);
|
||||
setIsCreatingWorktree(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectDirectory) {
|
||||
setAvailableWorktrees([]);
|
||||
setIsGitRepository(null);
|
||||
setIsCheckingGitRepository(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingWorktrees(true);
|
||||
setIsCheckingGitRepository(true);
|
||||
setWorktreeError(null);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const repoStatus = await checkIsGitRepository(projectDirectory);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setIsGitRepository(repoStatus);
|
||||
|
||||
if (!repoStatus) {
|
||||
setAvailableWorktrees([]);
|
||||
setWorktreeError(null);
|
||||
} else {
|
||||
const worktrees = await listGitWorktrees(projectDirectory);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectDirectory, info));
|
||||
const worktreeRoot = joinWorktreePath(projectDirectory, '');
|
||||
const worktreePrefix = `${worktreeRoot}/`;
|
||||
const filtered = mapped.filter((item) => item.path.startsWith(worktreePrefix));
|
||||
setAvailableWorktrees(filtered);
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Failed to load worktrees';
|
||||
setWorktreeError(message);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingWorktrees(false);
|
||||
setIsCheckingGitRepository(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isSessionCreateDialogOpen, projectDirectory]);
|
||||
|
||||
const openDeleteDialog = React.useCallback((payload: { sessions: Session[]; dateLabel?: string; mode?: 'session' | 'worktree'; worktree?: WorktreeMetadata | null }) => {
|
||||
setDeleteDialog({
|
||||
sessions: payload.sessions,
|
||||
dateLabel: payload.dateLabel,
|
||||
mode: payload.mode ?? 'session',
|
||||
worktree: payload.worktree ?? null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeDeleteDialog = React.useCallback(() => {
|
||||
setDeleteDialog(null);
|
||||
setDeleteDialogSummaries([]);
|
||||
setDeleteDialogShouldRemoveRemote(false);
|
||||
setIsProcessingDelete(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
return sessionEvents.onDeleteRequest((payload) => {
|
||||
openDeleteDialog(payload);
|
||||
});
|
||||
}, [openDeleteDialog]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return sessionEvents.onDirectoryRequest(() => {
|
||||
setIsDirectoryDialogOpen(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
return sessionEvents.onCreateRequest(() => {
|
||||
setBranchName('');
|
||||
setSessionCreateDialogOpen(true);
|
||||
});
|
||||
}, [setSessionCreateDialogOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!deleteDialog) {
|
||||
setDeleteDialogSummaries([]);
|
||||
setDeleteDialogShouldRemoveRemote(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const summaries = deleteDialog.sessions
|
||||
.map((session) => {
|
||||
const metadata = getWorktreeMetadata(session.id);
|
||||
return metadata ? { session, metadata } : null;
|
||||
})
|
||||
.filter((entry): entry is { session: Session; metadata: WorktreeMetadata } => Boolean(entry));
|
||||
|
||||
setDeleteDialogSummaries(summaries);
|
||||
setDeleteDialogShouldRemoveRemote(false);
|
||||
|
||||
if (summaries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const statuses = await Promise.all(
|
||||
summaries.map(async ({ metadata }) => {
|
||||
if (metadata.status && typeof metadata.status.isDirty === 'boolean') {
|
||||
return metadata.status;
|
||||
}
|
||||
try {
|
||||
return await getWorktreeStatus(metadata.path);
|
||||
} catch {
|
||||
return metadata.status;
|
||||
}
|
||||
})
|
||||
).catch((error) => {
|
||||
console.warn('Failed to inspect worktree status before deletion:', error);
|
||||
return summaries.map(({ metadata }) => metadata.status);
|
||||
});
|
||||
|
||||
if (cancelled || !Array.isArray(statuses)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteDialogSummaries((prev) =>
|
||||
prev.map((entry, index) => ({
|
||||
session: entry.session,
|
||||
metadata: { ...entry.metadata, status: statuses[index] ?? entry.metadata.status },
|
||||
}))
|
||||
);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [deleteDialog, getWorktreeMetadata]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!canRemoveRemoteBranches) {
|
||||
setDeleteDialogShouldRemoveRemote(false);
|
||||
}
|
||||
}, [canRemoveRemoteBranches]);
|
||||
|
||||
const handleBranchInputChange = React.useCallback((value: string) => {
|
||||
setBranchName(value);
|
||||
setWorktreeError(null);
|
||||
}, []);
|
||||
|
||||
const refreshWorktrees = React.useCallback(async () => {
|
||||
if (!projectDirectory || !isGitRepository) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const worktrees = await listGitWorktrees(projectDirectory);
|
||||
const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectDirectory, info));
|
||||
const worktreeRoot = joinWorktreePath(projectDirectory, '');
|
||||
const worktreePrefix = `${worktreeRoot}/`;
|
||||
const filtered = mapped.filter((item) => item.path.startsWith(worktreePrefix));
|
||||
setAvailableWorktrees(filtered);
|
||||
} catch { /* ignored */ }
|
||||
}, [projectDirectory, isGitRepository]);
|
||||
|
||||
const prevDeleteDialogRef = React.useRef<DeleteDialogState | null>(null);
|
||||
React.useEffect(() => {
|
||||
|
||||
if (prevDeleteDialogRef.current?.mode === 'worktree' && !deleteDialog) {
|
||||
refreshWorktrees();
|
||||
}
|
||||
prevDeleteDialogRef.current = deleteDialog;
|
||||
}, [deleteDialog, refreshWorktrees]);
|
||||
|
||||
const validateWorktreeCreation = React.useCallback((): boolean => {
|
||||
if (!projectDirectory) {
|
||||
const message = 'Select a project directory first.';
|
||||
setWorktreeError(message);
|
||||
toast.error(message);
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedBranch = sanitizedBranchName;
|
||||
const slugValue = sanitizedWorktreeSlug;
|
||||
if (!normalizedBranch) {
|
||||
const message = 'Provide a branch name for the new worktree.';
|
||||
setWorktreeError(message);
|
||||
toast.error(message);
|
||||
return false;
|
||||
}
|
||||
if (!slugValue) {
|
||||
const message = 'Provide a branch name that can be used as a folder.';
|
||||
setWorktreeError(message);
|
||||
toast.error(message);
|
||||
return false;
|
||||
}
|
||||
const prospectivePath = joinWorktreePath(projectDirectory, slugValue);
|
||||
if (availableWorktrees.some((worktree) => worktree.path === prospectivePath)) {
|
||||
const message = 'A worktree with this folder already exists.';
|
||||
setWorktreeError(message);
|
||||
toast.error(message);
|
||||
return false;
|
||||
}
|
||||
|
||||
setWorktreeError(null);
|
||||
return true;
|
||||
}, [projectDirectory, sanitizedBranchName, sanitizedWorktreeSlug, availableWorktrees]);
|
||||
|
||||
const handleCreateWorktree = async () => {
|
||||
if (isCreatingWorktree || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateWorktreeCreation()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingWorktree(true);
|
||||
setWorktreeError(null);
|
||||
|
||||
let cleanupMetadata: WorktreeMetadata | null = null;
|
||||
|
||||
try {
|
||||
const normalizedBranch = sanitizedBranchName;
|
||||
const slugValue = sanitizedWorktreeSlug;
|
||||
const metadata = await createWorktree({
|
||||
projectDirectory,
|
||||
worktreeSlug: slugValue,
|
||||
branch: normalizedBranch,
|
||||
createBranch: true,
|
||||
});
|
||||
cleanupMetadata = metadata;
|
||||
let status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
||||
try {
|
||||
await gitPush(metadata.path, {
|
||||
remote: 'origin',
|
||||
branch: normalizedBranch,
|
||||
options: ['--set-upstream'],
|
||||
});
|
||||
status = await getWorktreeStatus(metadata.path).catch(() => status);
|
||||
toast.success(`Configured upstream for ${normalizedBranch}`);
|
||||
} catch (pushError) {
|
||||
const message =
|
||||
pushError instanceof Error ? pushError.message : 'Unable to push new worktree branch.';
|
||||
toast.warning('Worktree created locally', {
|
||||
description: renderToastDescription(`Upstream setup failed: ${message}`),
|
||||
});
|
||||
}
|
||||
const createdMetadata = status ? { ...metadata, status } : metadata;
|
||||
|
||||
const session = await createSession(undefined, metadata.path);
|
||||
if (!session) {
|
||||
await removeWorktree({ projectDirectory, path: metadata.path, force: true }).catch(() => undefined);
|
||||
const message = 'Failed to create session for worktree';
|
||||
setWorktreeError(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
initializeNewOpenChamberSession(session.id, agents);
|
||||
setSessionDirectory(session.id, metadata.path);
|
||||
setWorktreeMetadata(session.id, createdMetadata);
|
||||
|
||||
await refreshWorktrees();
|
||||
setBranchName('');
|
||||
toast.success('Worktree created');
|
||||
} catch (error) {
|
||||
if (cleanupMetadata) {
|
||||
await removeWorktree({ projectDirectory, path: cleanupMetadata.path, force: true }).catch(() => undefined);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree';
|
||||
setWorktreeError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsCreatingWorktree(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => {
|
||||
|
||||
const worktreeSessions = sessions.filter((session) => {
|
||||
const metadata = getWorktreeMetadata(session.id);
|
||||
return metadata?.path === worktree.path;
|
||||
});
|
||||
|
||||
sessionEvents.requestDelete({
|
||||
sessions: worktreeSessions,
|
||||
mode: 'worktree',
|
||||
worktree,
|
||||
});
|
||||
}, [sessions, getWorktreeMetadata]);
|
||||
|
||||
const handleConfirmDelete = React.useCallback(async () => {
|
||||
if (!deleteDialog) {
|
||||
return;
|
||||
}
|
||||
setIsProcessingDelete(true);
|
||||
|
||||
try {
|
||||
const archiveWorktree = shouldArchiveWorktree;
|
||||
const removeRemoteBranch = archiveWorktree && deleteDialogShouldRemoveRemote;
|
||||
|
||||
if (deleteDialog.sessions.length === 1) {
|
||||
const target = deleteDialog.sessions[0];
|
||||
const success = await deleteSession(target.id, {
|
||||
archiveWorktree,
|
||||
deleteRemoteBranch: removeRemoteBranch,
|
||||
});
|
||||
if (!success) {
|
||||
toast.error('Failed to delete session');
|
||||
setIsProcessingDelete(false);
|
||||
return;
|
||||
}
|
||||
const archiveNote = archiveWorktree
|
||||
? removeRemoteBranch
|
||||
? 'Worktree and remote branch removed.'
|
||||
: 'Attached worktree archived.'
|
||||
: undefined;
|
||||
toast.success('Session deleted', {
|
||||
description: renderToastDescription(archiveNote),
|
||||
});
|
||||
} else {
|
||||
const ids = deleteDialog.sessions.map((session) => session.id);
|
||||
const { deletedIds, failedIds } = await deleteSessions(ids, {
|
||||
archiveWorktree,
|
||||
deleteRemoteBranch: removeRemoteBranch,
|
||||
});
|
||||
|
||||
if (deletedIds.length > 0) {
|
||||
const archiveNote = archiveWorktree
|
||||
? removeRemoteBranch
|
||||
? 'Archived worktrees and removed remote branches.'
|
||||
: 'Attached worktrees archived.'
|
||||
: undefined;
|
||||
const successDescription =
|
||||
failedIds.length > 0
|
||||
? `${failedIds.length} session${failedIds.length === 1 ? '' : 's'} could not be deleted.`
|
||||
: deleteDialog.dateLabel
|
||||
? `Removed all sessions from ${deleteDialog.dateLabel}.`
|
||||
: undefined;
|
||||
const combinedDescription = [successDescription, archiveNote].filter(Boolean).join(' ');
|
||||
toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, {
|
||||
description: renderToastDescription(combinedDescription || undefined),
|
||||
});
|
||||
}
|
||||
|
||||
if (failedIds.length > 0) {
|
||||
toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`, {
|
||||
description: renderToastDescription('Please try again in a moment.'),
|
||||
});
|
||||
if (deletedIds.length === 0) {
|
||||
setIsProcessingDelete(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeDeleteDialog();
|
||||
} finally {
|
||||
setIsProcessingDelete(false);
|
||||
}
|
||||
}, [deleteDialog, deleteDialogShouldRemoveRemote, deleteSession, deleteSessions, closeDeleteDialog, shouldArchiveWorktree]);
|
||||
|
||||
const worktreeManagerBody = (
|
||||
<div className="space-y-4 w-full min-w-0">
|
||||
{}
|
||||
<div className="space-y-3 rounded-xl border border-border/40 bg-sidebar/60 p-3">
|
||||
<div className="space-y-1">
|
||||
<p className="typography-ui-label font-medium text-foreground">Create worktree</p>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Branch-specific directory under <code className="font-mono text-xs text-muted-foreground">{WORKTREE_ROOT}</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-branch-input">
|
||||
Branch name
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="worktree-branch-input"
|
||||
value={branchName}
|
||||
onChange={(e) => handleBranchInputChange(e.target.value)}
|
||||
placeholder="feature/new-branch"
|
||||
className="h-8 flex-1 typography-meta text-foreground placeholder:text-muted-foreground/70"
|
||||
disabled={!isGitRepo || isCheckingGitRepository}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !isCreatingWorktree) {
|
||||
handleCreateWorktree();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateWorktree}
|
||||
disabled={isCreatingWorktree || isLoading || !isGitRepo || !sanitizedBranchName}
|
||||
className="h-8"
|
||||
>
|
||||
{isCreatingWorktree ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
{sanitizedBranchName && (
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
Creates branch{' '}
|
||||
<code className="font-mono text-xs text-muted-foreground">{sanitizedBranchName}</code>
|
||||
{' '}at{' '}
|
||||
<code className="font-mono text-xs text-muted-foreground break-all">
|
||||
{formatPathForDisplay(worktreePreviewPath, homeDirectory)}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{worktreeError && <p className="typography-meta text-destructive">{worktreeError}</p>}
|
||||
{!isGitRepo && !isCheckingGitRepository && (
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
Current directory is not a Git repository.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-3 rounded-xl border border-border/40 bg-sidebar/60 p-3 overflow-hidden min-w-0">
|
||||
<div className="space-y-1">
|
||||
<p className="typography-ui-label font-medium text-foreground">Existing worktrees</p>
|
||||
</div>
|
||||
|
||||
{isLoadingWorktrees ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading worktrees…</p>
|
||||
) : availableWorktrees.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
No worktrees found under <code className="font-mono text-xs text-muted-foreground">{WORKTREE_ROOT}</code>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
{availableWorktrees.map((worktree) => {
|
||||
|
||||
const relativePath = worktree.relativePath
|
||||
|| (worktree.path.startsWith(projectDirectory + '/')
|
||||
? worktree.path.slice(projectDirectory.length + 1)
|
||||
: worktree.path);
|
||||
return (
|
||||
<div
|
||||
key={worktree.path}
|
||||
className="flex items-center gap-2 rounded-lg border border-border/30 bg-sidebar-accent/20 px-3 py-2 min-w-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="typography-meta font-medium text-foreground">
|
||||
{worktree.label || worktree.branch || 'Detached HEAD'}
|
||||
</p>
|
||||
<p className="typography-micro text-muted-foreground/70 break-all">
|
||||
{relativePath}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteWorktree(worktree)}
|
||||
className="flex-shrink-0 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`Delete worktree ${worktree.branch || worktree.label}`}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const worktreeManagerActions = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setSessionCreateDialogOpen(false)}
|
||||
disabled={isCreatingWorktree}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
);
|
||||
|
||||
const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null;
|
||||
const deleteDialogDescription = deleteDialog
|
||||
? deleteDialog.mode === 'worktree'
|
||||
? `This removes the selected worktree and ${deleteDialog.sessions.length === 1 ? '1 linked session' : `${deleteDialog.sessions.length} linked sessions`}.`
|
||||
: `This action permanently removes ${deleteDialog.sessions.length === 1 ? '1 session' : `${deleteDialog.sessions.length} sessions`}${deleteDialog.dateLabel ? ` from ${deleteDialog.dateLabel}` : ''
|
||||
}.`
|
||||
: '';
|
||||
|
||||
const deleteDialogBody = deleteDialog ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5 rounded-xl border border-border/40 bg-sidebar/60 p-3">
|
||||
<ul className="space-y-0.5">
|
||||
{deleteDialog.sessions.slice(0, 3).map((session) => (
|
||||
<li key={session.id} className="typography-micro text-muted-foreground/80">
|
||||
{session.title || 'Untitled Session'}
|
||||
</li>
|
||||
))}
|
||||
{deleteDialog.sessions.length > 3 && (
|
||||
<li className="typography-micro text-muted-foreground/70">
|
||||
+{deleteDialog.sessions.length - 3} more
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{isWorktreeDelete ? (
|
||||
<div className="space-y-2 rounded-xl border border-border/40 bg-sidebar/60 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-meta font-medium text-foreground">Worktree</span>
|
||||
{targetWorktree?.label ? (
|
||||
<span className="typography-micro text-muted-foreground/70">{targetWorktree.label}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground/80 break-all">
|
||||
{targetWorktree ? formatPathForDisplay(targetWorktree.path, homeDirectory) : 'Worktree path unavailable.'}
|
||||
</p>
|
||||
{hasDirtyWorktrees && (
|
||||
<p className="typography-micro text-warning">Uncommitted changes will be discarded.</p>
|
||||
)}
|
||||
|
||||
{canRemoveRemoteBranches ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (removeRemoteOptionDisabled) {
|
||||
return;
|
||||
}
|
||||
setDeleteDialogShouldRemoveRemote((prev) => !prev);
|
||||
}}
|
||||
disabled={removeRemoteOptionDisabled}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 rounded-xl border border-border/40 bg-sidebar/70 px-3 py-2 text-left',
|
||||
removeRemoteOptionDisabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'hover:bg-sidebar/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
||||
)}
|
||||
>
|
||||
<span className="mt-0.5 flex size-5 items-center justify-center text-muted-foreground">
|
||||
{deleteDialogShouldRemoveRemote ? (
|
||||
<RiCheckboxLine className="size-4 text-primary" />
|
||||
) : (
|
||||
<RiCheckboxBlankLine className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
<div className="flex-1 space-y-1">
|
||||
<span className="typography-meta font-medium text-foreground">Delete remote branch</span>
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
{deleteDialogShouldRemoveRemote
|
||||
? 'Remote branch on origin will also be removed.'
|
||||
: 'Keep the remote branch intact.'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
Remote branch information unavailable for this worktree.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/40 bg-sidebar/60 p-3">
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Worktree directories stay intact. Subsessions linked to the selected sessions will also be removed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const deleteDialogActions = (
|
||||
<>
|
||||
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isProcessingDelete}>
|
||||
{isProcessingDelete
|
||||
? 'Deleting…'
|
||||
: isWorktreeDelete
|
||||
? 'Delete worktree'
|
||||
: deleteDialog?.sessions.length === 1
|
||||
? 'Delete session'
|
||||
: 'Delete sessions'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{useMobileOverlay ? (
|
||||
<MobileOverlayPanel
|
||||
open={isSessionCreateDialogOpen}
|
||||
onClose={() => setSessionCreateDialogOpen(false)}
|
||||
title="Worktree Manager"
|
||||
footer={<div className="flex justify-end gap-2">{worktreeManagerActions}</div>}
|
||||
>
|
||||
<div className="space-y-2 pb-2">
|
||||
{worktreeManagerBody}
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
) : (
|
||||
<Dialog open={isSessionCreateDialogOpen} onOpenChange={setSessionCreateDialogOpen}>
|
||||
<DialogContent className="max-w-[min(520px,100vw-2rem)] space-y-2 pb-2 overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Worktree Manager</DialogTitle>
|
||||
</DialogHeader>
|
||||
{worktreeManagerBody}
|
||||
<DialogFooter className="mt-2 gap-2 pt-1 pb-1">{worktreeManagerActions}</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{useMobileOverlay ? (
|
||||
<MobileOverlayPanel
|
||||
open={Boolean(deleteDialog)}
|
||||
onClose={() => {
|
||||
if (isProcessingDelete) {
|
||||
return;
|
||||
}
|
||||
closeDeleteDialog();
|
||||
}}
|
||||
title={deleteDialog?.sessions.length === 1 ? 'Delete session' : 'Delete sessions'}
|
||||
footer={<div className="flex justify-end gap-2">{deleteDialogActions}</div>}
|
||||
>
|
||||
<div className="space-y-2 pb-2">
|
||||
{deleteDialogDescription && (
|
||||
<p className="typography-meta text-muted-foreground/80">{deleteDialogDescription}</p>
|
||||
)}
|
||||
{deleteDialogBody}
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
) : (
|
||||
<Dialog
|
||||
open={Boolean(deleteDialog)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
if (isProcessingDelete) {
|
||||
return;
|
||||
}
|
||||
closeDeleteDialog();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[min(520px,100vw-2rem)] space-y-2 pb-2">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{deleteDialog?.sessions.length === 1 ? 'Delete session' : 'Delete sessions'}</DialogTitle>
|
||||
{deleteDialogDescription && <DialogDescription>{deleteDialogDescription}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
{deleteDialogBody}
|
||||
<DialogFooter className="mt-2 gap-2 pt-1 pb-1">{deleteDialogActions}</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<DirectoryExplorerDialog
|
||||
open={isDirectoryDialogOpen}
|
||||
onOpenChange={setIsDirectoryDialogOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user