feat: add repository cloning to project setup
Add project can clone repositories into a chosen folder Command palette now opens the add project flow Clone flow supports selecting Git identities
This commit is contained in:
@@ -11,9 +11,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui';
|
||||
import { IdentityDropdown } from '@/components/views/git/GitHeader';
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowLeftSLine,
|
||||
@@ -152,6 +154,12 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
|
||||
const projects = useProjectsStore((s) => s.projects);
|
||||
const addProject = useProjectsStore((s) => s.addProject);
|
||||
const gitIdentityProfiles = useGitIdentitiesStore((s) => s.profiles);
|
||||
const globalGitIdentity = useGitIdentitiesStore((s) => s.globalIdentity);
|
||||
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||
const loadGitIdentityProfiles = useGitIdentitiesStore((s) => s.loadProfiles);
|
||||
const loadGlobalGitIdentity = useGitIdentitiesStore((s) => s.loadGlobalIdentity);
|
||||
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
||||
const showHidden = useDirectoryShowHidden();
|
||||
const { isDesktop, requestAccess, startAccessing } = useFileSystemAccess();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
@@ -167,6 +175,9 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
const [isConfirming, setIsConfirming] = React.useState(false);
|
||||
const [isOpeningFinder, setIsOpeningFinder] = React.useState(false);
|
||||
const [addButtonWidth, setAddButtonWidth] = React.useState(0);
|
||||
const [isCloneMode, setIsCloneMode] = React.useState(false);
|
||||
const [cloneRemoteUrl, setCloneRemoteUrl] = React.useState('');
|
||||
const [selectedGitIdentityId, setSelectedGitIdentityId] = React.useState<string | null>(null);
|
||||
|
||||
const explorerRootDirectory = dialogHomeDirectory || homeDirectory;
|
||||
|
||||
@@ -183,6 +194,9 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
setHighlightedIndex(0);
|
||||
setIsConfirming(false);
|
||||
setIsOpeningFinder(false);
|
||||
setIsCloneMode(false);
|
||||
setCloneRemoteUrl('');
|
||||
setSelectedGitIdentityId(null);
|
||||
requestAnimationFrame(() => focusPathInput(inputRef.current));
|
||||
|
||||
let cancelled = false;
|
||||
@@ -198,6 +212,42 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
};
|
||||
}, [homeDirectory, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
void loadGitIdentityProfiles();
|
||||
void loadGlobalGitIdentity();
|
||||
void loadDefaultGitIdentityId();
|
||||
}, [loadDefaultGitIdentityId, loadGitIdentityProfiles, loadGlobalGitIdentity, open]);
|
||||
|
||||
const availableGitIdentities = React.useMemo(() => {
|
||||
const unique = new Map<string, NonNullable<typeof globalGitIdentity>>();
|
||||
if (globalGitIdentity) {
|
||||
unique.set(globalGitIdentity.id, globalGitIdentity);
|
||||
}
|
||||
for (const profile of gitIdentityProfiles) {
|
||||
unique.set(profile.id, profile);
|
||||
}
|
||||
return Array.from(unique.values());
|
||||
}, [gitIdentityProfiles, globalGitIdentity]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !isCloneMode || selectedGitIdentityId !== null) return;
|
||||
const defaultId = typeof defaultGitIdentityId === 'string' ? defaultGitIdentityId.trim() : '';
|
||||
if (defaultId && availableGitIdentities.some((identity) => identity.id === defaultId)) {
|
||||
setSelectedGitIdentityId(defaultId);
|
||||
return;
|
||||
}
|
||||
const firstSshIdentity = availableGitIdentities.find((identity) => identity.authType === 'ssh' || identity.sshKey);
|
||||
if (firstSshIdentity) {
|
||||
setSelectedGitIdentityId(firstSshIdentity.id);
|
||||
}
|
||||
}, [availableGitIdentities, defaultGitIdentityId, isCloneMode, open, selectedGitIdentityId]);
|
||||
|
||||
const selectedGitIdentity = React.useMemo(
|
||||
() => availableGitIdentities.find((identity) => identity.id === selectedGitIdentityId) ?? null,
|
||||
[availableGitIdentities, selectedGitIdentityId]
|
||||
);
|
||||
|
||||
const browseDirectoryDisplayPath = React.useMemo(() => getBrowseDirectoryPath(query), [query]);
|
||||
const browseFilterQuery = React.useMemo(
|
||||
() => (hasTrailingPathSeparator(query) ? '' : getBrowseLeafPathSegment(query)),
|
||||
@@ -294,6 +344,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
)
|
||||
);
|
||||
const canAddProject = !isConfirming && !isOpeningFinder && !isAlreadyAdded && Boolean(targetPath);
|
||||
const canSubmitClone = canAddProject && cloneRemoteUrl.trim().length > 0;
|
||||
const highlightedRow = rows[highlightedIndex] ?? null;
|
||||
const hasHighlightedBrowseItem = Boolean(
|
||||
highlightedRow && (highlightedRow.type === 'up' || (highlightedRow.type === 'directory' && !highlightedRow.disabled))
|
||||
@@ -303,6 +354,12 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
: 'Ctrl';
|
||||
const submitActionLabel = isAlreadyAdded
|
||||
? t('directoryExplorerDialog.actions.alreadyAdded')
|
||||
: isCloneMode
|
||||
? isConfirming
|
||||
? t('directoryExplorerDialog.actions.cloning')
|
||||
: t('directoryExplorerDialog.actions.cloneAndAdd')
|
||||
: isConfirming
|
||||
? t('directoryExplorerDialog.actions.adding')
|
||||
: shouldCreateTarget
|
||||
? t('directoryExplorerDialog.actions.createAndAdd')
|
||||
: t('directoryExplorerDialog.actions.addProject');
|
||||
@@ -345,14 +402,27 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
if (!target || isConfirming) return;
|
||||
const normalized = normalizeDirectoryPath(target);
|
||||
if (normalized && addedProjectPaths.has(normalized)) return;
|
||||
let selectedTarget = target;
|
||||
|
||||
setIsConfirming(true);
|
||||
try {
|
||||
const shouldCreateSelection = shouldCreateTarget && normalizeDirectoryPath(target) === normalizeDirectoryPath(targetPath);
|
||||
if (shouldCreateSelection) {
|
||||
const shouldCreateSelection = !isCloneMode && shouldCreateTarget && normalizeDirectoryPath(target) === normalizeDirectoryPath(targetPath);
|
||||
if (isCloneMode) {
|
||||
const remoteUrl = cloneRemoteUrl.trim();
|
||||
if (!remoteUrl) {
|
||||
toast.error(t('directoryExplorerDialog.toast.cloneUrlRequired'));
|
||||
return;
|
||||
}
|
||||
const result = await opencodeClient.cloneRepository({
|
||||
remoteUrl,
|
||||
destinationPath: target,
|
||||
gitIdentityId: selectedGitIdentity?.id ?? null,
|
||||
});
|
||||
selectedTarget = result.path;
|
||||
} else if (shouldCreateSelection) {
|
||||
await opencodeClient.createDirectory(target, { allowOutsideWorkspace: true });
|
||||
}
|
||||
const added = addProject(target);
|
||||
const added = addProject(selectedTarget);
|
||||
if (!added) {
|
||||
toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), {
|
||||
description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'),
|
||||
@@ -367,7 +437,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
} finally {
|
||||
setIsConfirming(false);
|
||||
}
|
||||
}, [addProject, addedProjectPaths, handleClose, isConfirming, shouldCreateTarget, targetPath, t]);
|
||||
}, [addProject, addedProjectPaths, cloneRemoteUrl, handleClose, isCloneMode, isConfirming, selectedGitIdentity?.id, shouldCreateTarget, targetPath, t]);
|
||||
|
||||
const browseToDisplayPath = React.useCallback((displayPath: string) => {
|
||||
setQuery(ensureBrowseDirectoryPath(displayPath));
|
||||
@@ -459,36 +529,59 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
);
|
||||
|
||||
const inputSection = (
|
||||
<div className="relative px-2.5 py-1.5">
|
||||
<RiFolderAddLine className="pointer-events-none absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/80" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(normalizeSeparators(event.target.value))}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('directoryExplorerDialog.pathInput.placeholder')}
|
||||
className="border-transparent bg-transparent pl-9 font-mono typography-ui-label shadow-none focus-visible:ring-0"
|
||||
style={!isMobile && addButtonWidth > 0 ? { paddingRight: `${addButtonWidth + 24}px` } : undefined}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
{!isMobile ? (
|
||||
<Button
|
||||
ref={addButtonRef}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
tabIndex={-1}
|
||||
className="absolute right-4 top-1/2 h-7 -translate-y-1/2 gap-1 px-2 typography-meta"
|
||||
disabled={!canAddProject}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void finalizeSelection(targetPath)}
|
||||
title={submitActionLabel}
|
||||
>
|
||||
{submitActionLabel}
|
||||
</Button>
|
||||
<div className="px-2.5 py-1.5">
|
||||
{isCloneMode ? (
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<Input
|
||||
value={cloneRemoteUrl}
|
||||
onChange={(event) => setCloneRemoteUrl(event.target.value)}
|
||||
placeholder={t('directoryExplorerDialog.clone.remoteUrlPlaceholder')}
|
||||
className="min-w-0 flex-1 border-border/60 bg-[var(--surface-elevated)] font-mono typography-ui-label shadow-none"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
<IdentityDropdown
|
||||
activeProfile={selectedGitIdentity}
|
||||
identities={availableGitIdentities}
|
||||
onSelect={(profile) => setSelectedGitIdentityId(profile.id)}
|
||||
isApplying={isConfirming}
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative">
|
||||
<RiFolderAddLine className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/80" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(normalizeSeparators(event.target.value))}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('directoryExplorerDialog.pathInput.placeholder')}
|
||||
className="border-transparent bg-transparent pl-9 font-mono typography-ui-label shadow-none focus-visible:ring-0"
|
||||
style={!isMobile && addButtonWidth > 0 ? { paddingRight: `${addButtonWidth + 24}px` } : undefined}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
{!isMobile ? (
|
||||
<Button
|
||||
ref={addButtonRef}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
tabIndex={-1}
|
||||
className="absolute right-1.5 top-1/2 h-7 -translate-y-1/2 gap-1 px-2 typography-meta"
|
||||
disabled={isCloneMode ? !canSubmitClone : !canAddProject}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void finalizeSelection(targetPath)}
|
||||
title={submitActionLabel}
|
||||
>
|
||||
{submitActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -577,10 +670,6 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
<RiCornerDownLeftLine className="h-3.5 w-3.5" />
|
||||
{t('directoryExplorerDialog.footer.add')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>Esc</span>
|
||||
{t('directoryExplorerDialog.footer.close')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -589,15 +678,15 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
{!isMobile ? footerHints : null}
|
||||
<div className={cn('flex w-full flex-row justify-end gap-2 sm:w-auto', isMobile && 'justify-stretch')}>
|
||||
{isDesktop ? (
|
||||
<Button variant="ghost" size="xs" onClick={handleOpenInFinder} disabled={isConfirming || isOpeningFinder}>
|
||||
<Button variant="ghost" size="xs" onClick={handleOpenInFinder} disabled={isConfirming || isOpeningFinder || isCloneMode}>
|
||||
{isOpeningFinder ? t('directoryExplorerDialog.actions.openingFinder') : t('directoryExplorerDialog.actions.openInFinder')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="xs" onClick={handleClose} disabled={isConfirming || isOpeningFinder} className={cn(isMobile && 'flex-1')}>
|
||||
{t('directoryExplorerDialog.actions.cancel')}
|
||||
<Button variant="ghost" size="xs" onClick={() => setIsCloneMode((value) => !value)} disabled={isConfirming || isOpeningFinder} className={cn(isMobile && 'flex-1')}>
|
||||
{isCloneMode ? t('directoryExplorerDialog.actions.addLocalProject') : t('directoryExplorerDialog.actions.cloneRepository')}
|
||||
</Button>
|
||||
{isMobile ? (
|
||||
<Button size="xs" onClick={() => void finalizeSelection(targetPath)} disabled={!canAddProject} className="flex-1">
|
||||
<Button size="xs" onClick={() => void finalizeSelection(targetPath)} disabled={isCloneMode ? !canSubmitClone : !canAddProject} className="flex-1">
|
||||
{submitActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user