From d764ad6872eda4536bf89d9c09402c011133e6db Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 7 May 2026 01:10:17 +0300 Subject: [PATCH] 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 --- .../session/DirectoryExplorerDialog.tsx | 171 +++++++++++++----- packages/ui/src/components/ui/AboutDialog.tsx | 4 - .../ui/src/components/ui/CommandPalette.tsx | 11 ++ .../ui/src/components/views/git/GitHeader.tsx | 2 +- packages/ui/src/lib/i18n/messages/en.ts | 12 +- packages/ui/src/lib/i18n/messages/es.ts | 12 +- packages/ui/src/lib/i18n/messages/ko.ts | 12 +- packages/ui/src/lib/i18n/messages/pl.ts | 116 ++++++------ packages/ui/src/lib/i18n/messages/pt-BR.ts | 12 +- packages/ui/src/lib/i18n/messages/uk.ts | 12 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 12 +- packages/ui/src/lib/opencode/client.ts | 18 ++ packages/web/server/lib/fs/routes.js | 151 ++++++++++++++++ 13 files changed, 419 insertions(+), 126 deletions(-) diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index cc5009e9..14e04fae 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -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 = ( 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 = ( 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(null); const explorerRootDirectory = dialogHomeDirectory || homeDirectory; @@ -183,6 +194,9 @@ export const DirectoryExplorerDialog: React.FC = ( 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 = ( }; }, [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>(); + 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 = ( ) ); 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 = ( : '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 = ( 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 = ( } 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 = ( ); const inputSection = ( -
- - 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 ? ( - +
+ {isCloneMode ? ( +
+ 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" + /> + setSelectedGitIdentityId(profile.id)} + isApplying={isConfirming} + iconOnly + /> +
) : null} +
+ + 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 ? ( + + ) : null} +
); @@ -577,10 +670,6 @@ export const DirectoryExplorerDialog: React.FC = ( {t('directoryExplorerDialog.footer.add')} - - Esc - {t('directoryExplorerDialog.footer.close')} -
); @@ -589,15 +678,15 @@ export const DirectoryExplorerDialog: React.FC = ( {!isMobile ? footerHints : null}
{isDesktop ? ( - ) : null} - {isMobile ? ( - ) : null} diff --git a/packages/ui/src/components/ui/AboutDialog.tsx b/packages/ui/src/components/ui/AboutDialog.tsx index 01a79ed2..dc41cd87 100644 --- a/packages/ui/src/components/ui/AboutDialog.tsx +++ b/packages/ui/src/components/ui/AboutDialog.tsx @@ -125,10 +125,6 @@ export const AboutDialog: React.FC = ({ )}
-

- {t('aboutDialog.tagline')} -

-