diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 90f4f370..fc34e08a 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -151,6 +151,7 @@ export const DirectoryExplorerDialog: React.FC = ( const addProject = useProjectsStore((s) => s.addProject); const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); + const addProjects = useProjectsStore((s) => s.addProjects); const gitIdentityProfiles = useGitIdentitiesStore((s) => s.profiles); const globalGitIdentity = useGitIdentitiesStore((s) => s.globalIdentity); const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId); @@ -177,6 +178,7 @@ export const DirectoryExplorerDialog: React.FC = ( const [cloneRemoteUrl, setCloneRemoteUrl] = React.useState(''); const [selectedGitIdentityId, setSelectedGitIdentityId] = React.useState(null); const [showHidden, setShowHidden] = React.useState(false); + const [selectedPaths, setSelectedPaths] = React.useState([]); const explorerRootDirectory = dialogHomeDirectory || homeDirectory; @@ -197,6 +199,7 @@ export const DirectoryExplorerDialog: React.FC = ( setCloneRemoteUrl(''); setSelectedGitIdentityId(null); setShowHidden(false); + setSelectedPaths([]); requestAnimationFrame(() => focusPathInput(inputRef.current)); let cancelled = false; @@ -330,6 +333,27 @@ export const DirectoryExplorerDialog: React.FC = ( setHighlightedIndex(0); }, [query, rows.length]); + // Selections apply to the currently browsed directory: navigating into + // another folder clears the pending batch so the primary action always + // reflects the visible picker state. + React.useEffect(() => { + setSelectedPaths([]); + }, [browseDirectoryAbsolutePath]); + + const selectionPaths = React.useMemo( + () => selectedPaths.filter((path) => { + const normalized = normalizeDirectoryPath(path); + return Boolean(normalized && !addedProjectPaths.has(normalized)); + }), + [addedProjectPaths, selectedPaths] + ); + + const togglePathSelection = React.useCallback((path: string) => { + setSelectedPaths((prev) => ( + prev.includes(path) ? prev.filter((entry) => entry !== path) : [...prev, path] + )); + }, []); + const targetPath = React.useMemo(() => { if (!explorerRootDirectory) return ''; return trimTrailingSeparators(displayPathToAbsolutePath(query, explorerRootDirectory)); @@ -351,28 +375,29 @@ export const DirectoryExplorerDialog: React.FC = ( ); const canAddProject = !isConfirming && !isOpeningFinder - && !isAlreadyAdded && browseErrorReason !== 'os-permission' && browseErrorReason !== 'invalid-response' && browseErrorReason !== 'unknown' - && Boolean(targetPath); + && ((!isCloneMode && selectionPaths.length > 0) || (!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') ); const submitModifierLabel = formatShortcutForDisplay('mod'); - 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'); + const submitActionLabel = !isCloneMode && selectionPaths.length > 0 + ? t('directoryExplorerDialog.actions.addSelected') + : 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'); React.useLayoutEffect(() => { const button = addButtonRef.current; @@ -429,9 +454,17 @@ export const DirectoryExplorerDialog: React.FC = ( }, [addProject, addedProjectPaths, openProjectDraft, t]); const finalizeSelection = React.useCallback(async (target: string) => { - if (!target || isConfirming) return; + if (isConfirming) return; const normalized = normalizeDirectoryPath(target); - if (normalized && addedProjectPaths.has(normalized)) return; + // Batch selections supersede the single-target flow. Only the single-target + // flow is blocked by an already-added (or missing) directory. + const selectionToAdd = isCloneMode + ? [] + : selectedPaths.filter((path) => { + const selectionNormalized = normalizeDirectoryPath(path); + return Boolean(selectionNormalized && !addedProjectPaths.has(selectionNormalized)); + }); + if (selectionToAdd.length === 0 && (!target || (normalized && addedProjectPaths.has(normalized)))) return; let selectedTarget = target; setIsConfirming(true); @@ -449,6 +482,21 @@ export const DirectoryExplorerDialog: React.FC = ( gitIdentityId: selectedGitIdentity?.id ?? null, }); selectedTarget = result.path; + } else if (selectionToAdd.length > 0) { + // Batch path wins over single-target create: with checkboxes ticked, + // the user wants the selections added, not a fresh directory created + // for whatever happens to be typed in the filter. + const added = await addProjects(selectionToAdd); + if (added.length === 0) { + toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { + description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'), + }); + return; + } + toast.success(t('directoryExplorerDialog.toast.addedProjects', { count: added.length })); + setSelectedPaths([]); + handleClose(); + return; } else if (shouldCreateSelection) { await opencodeClient.createDirectory(target, { asProject: true }); } @@ -467,7 +515,7 @@ export const DirectoryExplorerDialog: React.FC = ( } finally { setIsConfirming(false); } - }, [addProject, addedProjectPaths, cloneRemoteUrl, isCloneMode, isConfirming, openProjectDraft, selectedGitIdentity?.id, shouldCreateTarget, targetPath, t]); + }, [addProject, addProjects, addedProjectPaths, cloneRemoteUrl, handleClose, isCloneMode, isConfirming, openProjectDraft, selectedGitIdentity?.id, selectedPaths, shouldCreateTarget, targetPath, t]); const browseToDisplayPath = React.useCallback((displayPath: string) => { setQuery(ensureBrowseDirectoryPath(displayPath)); @@ -508,6 +556,9 @@ export const DirectoryExplorerDialog: React.FC = ( return; } + // Clear pending selections so the Finder-sourced target is honored + // instead of silently being absorbed by the batch branch. + setSelectedPaths([]); await finalizeSelection(result.path); } catch (error) { toast.error(t('directoryExplorerDialog.toast.failedToSelectDirectory'), { @@ -529,6 +580,20 @@ export const DirectoryExplorerDialog: React.FC = ( setHighlightedIndex((index) => Math.max(0, index - 1)); return; } + if (event.key === ' ') { + // Only treat Space as a selection toggle when the user is actively + // browsing a directory (trailing slash or no filter typing). When + // the input is in path-entry mode, Space is a literal character + // and must reach the input value. + if (hasTrailingPathSeparator(query)) { + event.preventDefault(); + if (highlightedRow && highlightedRow.type === 'directory' && !highlightedRow.disabled) { + togglePathSelection(highlightedRow.path); + } + return; + } + return; + } if (event.key === 'Enter') { event.preventDefault(); if (isPrimaryModifierPressed(event)) { @@ -544,7 +609,7 @@ export const DirectoryExplorerDialog: React.FC = ( event.preventDefault(); handleClose(); } - }, [executeRow, finalizeSelection, handleClose, hasHighlightedBrowseItem, highlightedRow, query, rows.length, targetPath]); + }, [executeRow, finalizeSelection, handleClose, hasHighlightedBrowseItem, highlightedRow, query, rows.length, targetPath, togglePathSelection]); const showHiddenToggle = ( + <> + + + ) : null} ); @@ -738,7 +819,16 @@ export const DirectoryExplorerDialog: React.FC = ( {isOpeningFinder ? t('directoryExplorerDialog.actions.openingFinder') : t('directoryExplorerDialog.actions.openInFinder')} ) : null} - {isMobile ? ( diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index ae153393..542f082f 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1624,6 +1624,7 @@ export const dict = { 'directoryExplorerDialog.actions.openInFinder': 'Im Finder öffnen', 'directoryExplorerDialog.actions.adding': 'Füge hinzu...', 'directoryExplorerDialog.actions.addProject': 'Projekt hinzufügen', + 'directoryExplorerDialog.actions.addSelected': 'Ausgewählte hinzufügen', 'directoryExplorerDialog.actions.addLocalProject': 'Lokales Projekt hinzufügen', 'directoryExplorerDialog.actions.cloneRepository': 'Repository klonen', 'directoryExplorerDialog.actions.cloneAndAdd': 'Klonen & hinzufügen', @@ -1641,6 +1642,7 @@ export const dict = { 'directoryExplorerDialog.browse.parentDirectory': 'Übergeordnetes Verzeichnis', 'directoryExplorerDialog.browse.addedBadge': 'Hinzugefügt', 'directoryExplorerDialog.browse.quickAdd': 'Hinzufügen', + 'directoryExplorerDialog.browse.selectForAdd': 'Zum Hinzufügen auswählen', 'directoryExplorerDialog.footer.navigate': 'Navigieren', 'directoryExplorerDialog.footer.select': 'Auswählen', 'directoryExplorerDialog.footer.add': 'Hinzufügen', @@ -1649,6 +1651,7 @@ export const dict = { 'directoryExplorerDialog.toast.desktopDeniedAccess': 'Desktop hat den Zugriff auf das Verzeichnis verweigert.', 'directoryExplorerDialog.toast.failedToOpenDirectory': 'Fehler beim Öffnen des Verzeichnisses', 'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'Desktop konnte keinen Dateizugriff gewähren.', + 'directoryExplorerDialog.toast.addedProjects': '{count} Projekt(e) hinzugefügt', 'directoryExplorerDialog.toast.failedToAddProject': 'Fehler beim Hinzufügen des Projekts', 'directoryExplorerDialog.toast.cloneUrlRequired': 'Geben Sie eine Repository-URL ein, bevor Sie klonen.', 'directoryExplorerDialog.toast.selectValidDirectoryPath': 'Bitte wählen Sie einen gültigen Verzeichnispfad aus.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 96d89111..c693df08 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1825,6 +1825,7 @@ export const dict = { 'directoryExplorerDialog.actions.openInFinder': 'Open in Finder', 'directoryExplorerDialog.actions.adding': 'Adding...', 'directoryExplorerDialog.actions.addProject': 'Add project', + 'directoryExplorerDialog.actions.addSelected': 'Add selected', 'directoryExplorerDialog.actions.addLocalProject': 'Add local project', 'directoryExplorerDialog.actions.cloneRepository': 'Clone repository', 'directoryExplorerDialog.actions.cloneAndAdd': 'Clone & add', @@ -1842,6 +1843,7 @@ export const dict = { 'directoryExplorerDialog.browse.parentDirectory': 'Parent directory', 'directoryExplorerDialog.browse.addedBadge': 'Added', 'directoryExplorerDialog.browse.quickAdd': 'Add', + 'directoryExplorerDialog.browse.selectForAdd': 'Select for add', 'directoryExplorerDialog.footer.navigate': 'Navigate', 'directoryExplorerDialog.footer.select': 'Select', 'directoryExplorerDialog.footer.add': 'Add', @@ -1850,6 +1852,7 @@ export const dict = { 'directoryExplorerDialog.toast.desktopDeniedAccess': 'Desktop denied directory access.', 'directoryExplorerDialog.toast.failedToOpenDirectory': 'Failed to open directory', 'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'Desktop could not grant file access.', + 'directoryExplorerDialog.toast.addedProjects': 'Added {count} project(s)', 'directoryExplorerDialog.toast.failedToAddProject': 'Failed to add project', 'directoryExplorerDialog.toast.cloneUrlRequired': 'Enter a repository URL before cloning.', 'directoryExplorerDialog.toast.selectValidDirectoryPath': 'Please select a valid directory path.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 9e604795..38173466 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1803,6 +1803,7 @@ export const dict: Record = { "directoryExplorerDialog.actions.openInFinder": "Abrir en Finder", "directoryExplorerDialog.actions.adding": "Añadiendo...", "directoryExplorerDialog.actions.addProject": "Añadir proyecto", + "directoryExplorerDialog.actions.addSelected": "Añadir seleccionados", "directoryExplorerDialog.actions.addLocalProject": "Añadir proyecto local", "directoryExplorerDialog.actions.cloneRepository": "Clonar repositorio", "directoryExplorerDialog.actions.cloneAndAdd": "Clonar y añadir", @@ -1820,6 +1821,7 @@ export const dict: Record = { "directoryExplorerDialog.browse.parentDirectory": "Directorio padre", "directoryExplorerDialog.browse.addedBadge": "Añadido", "directoryExplorerDialog.browse.quickAdd": "Añadir", + "directoryExplorerDialog.browse.selectForAdd": "Seleccionar para añadir", "directoryExplorerDialog.footer.navigate": "Navegar", "directoryExplorerDialog.footer.select": "Seleccionar", "directoryExplorerDialog.footer.add": "Añadir", @@ -1828,6 +1830,7 @@ export const dict: Record = { "directoryExplorerDialog.toast.desktopDeniedAccess": "El escritorio denegó el acceso al directorio.", "directoryExplorerDialog.toast.failedToOpenDirectory": "No se pudo abrir el directorio", "directoryExplorerDialog.toast.desktopCouldNotGrantAccess": "El escritorio no pudo otorgar acceso al archivo.", + "directoryExplorerDialog.toast.addedProjects": "Se añadieron {count} proyecto(s)", "directoryExplorerDialog.toast.failedToAddProject": "No se pudo añadir el proyecto", "directoryExplorerDialog.toast.cloneUrlRequired": "Introduce una URL de repositorio antes de clonar.", "directoryExplorerDialog.toast.selectValidDirectoryPath": "Por favor selecciona una ruta de directorio válida.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 316df20f..72c07450 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1582,6 +1582,7 @@ export const dict = { 'directoryExplorerDialog.actions.openInFinder': 'Ouvrir dans le Finder', 'directoryExplorerDialog.actions.adding': 'Ajout...', 'directoryExplorerDialog.actions.addProject': 'Ajouter un projet', + 'directoryExplorerDialog.actions.addSelected': 'Ajouter la sélection', 'directoryExplorerDialog.actions.addLocalProject': 'Ajouter un projet local', 'directoryExplorerDialog.actions.cloneRepository': 'Cloner le dépôt', 'directoryExplorerDialog.actions.cloneAndAdd': 'Cloner et ajouter', @@ -1599,6 +1600,7 @@ export const dict = { 'directoryExplorerDialog.browse.parentDirectory': 'Annuaire parent', 'directoryExplorerDialog.browse.addedBadge': 'Ajouté', 'directoryExplorerDialog.browse.quickAdd': 'Ajouter', + 'directoryExplorerDialog.browse.selectForAdd': 'Sélectionner pour ajouter', 'directoryExplorerDialog.footer.navigate': 'Naviguer', 'directoryExplorerDialog.footer.select': 'Sélectionner', 'directoryExplorerDialog.footer.add': 'Ajouter', @@ -1607,6 +1609,7 @@ export const dict = { 'directoryExplorerDialog.toast.desktopDeniedAccess': 'Le bureau a refusé l\'accès au répertoire.', 'directoryExplorerDialog.toast.failedToOpenDirectory': 'Échec de l\'ouverture du répertoire', 'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'Desktop n\'a pas pu accorder l\'accès aux fichiers.', + 'directoryExplorerDialog.toast.addedProjects': '{count} projet(s) ajouté(s)', 'directoryExplorerDialog.toast.failedToAddProject': 'Échec de l\'ajout du projet', 'directoryExplorerDialog.toast.cloneUrlRequired': 'Entrez dans un dépôt URL avant le clonage.', 'directoryExplorerDialog.toast.selectValidDirectoryPath': 'Veuillez sélectionner un chemin de répertoire valide.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 82af15b6..3a7fb45a 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1821,6 +1821,7 @@ export const dict: Record = { 'directoryExplorerDialog.actions.openInFinder': 'Finderで開く', 'directoryExplorerDialog.actions.adding': '追加中...', 'directoryExplorerDialog.actions.addProject': 'プロジェクトを追加', + 'directoryExplorerDialog.actions.addSelected': '選択したものを追加', 'directoryExplorerDialog.actions.addLocalProject': 'ローカルプロジェクトを追加', 'directoryExplorerDialog.actions.cloneRepository': 'リポジトリをクローン', 'directoryExplorerDialog.actions.cloneAndAdd': 'クローンして追加', @@ -1838,6 +1839,7 @@ export const dict: Record = { 'directoryExplorerDialog.browse.parentDirectory': '親ディレクトリ', 'directoryExplorerDialog.browse.addedBadge': '追加済み', 'directoryExplorerDialog.browse.quickAdd': '追加', + 'directoryExplorerDialog.browse.selectForAdd': '追加するものを選択', 'directoryExplorerDialog.footer.navigate': '移動', 'directoryExplorerDialog.footer.select': '選択', 'directoryExplorerDialog.footer.add': '追加', @@ -1846,6 +1848,7 @@ export const dict: Record = { 'directoryExplorerDialog.toast.desktopDeniedAccess': 'デスクトップがディレクトリアクセスを拒否しました。', 'directoryExplorerDialog.toast.failedToOpenDirectory': 'ディレクトリを開けませんでした', 'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'デスクトップがファイルアクセスを許可できませんでした。', + 'directoryExplorerDialog.toast.addedProjects': '{count}件のプロジェクトを追加しました', 'directoryExplorerDialog.toast.failedToAddProject': 'プロジェクトの追加に失敗しました', 'directoryExplorerDialog.toast.cloneUrlRequired': 'クローンする前にリポジトリURLを入力してください。', 'directoryExplorerDialog.toast.selectValidDirectoryPath': '有効なディレクトリパスを選択してください。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 32c02d9f..43c1c4e4 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1827,6 +1827,7 @@ export const dict: Record = { 'directoryExplorerDialog.actions.openInFinder': 'Finder에서 열기', 'directoryExplorerDialog.actions.adding': '추가 중…', 'directoryExplorerDialog.actions.addProject': '프로젝트 추가', + 'directoryExplorerDialog.actions.addSelected': '선택 항목 추가', 'directoryExplorerDialog.actions.addLocalProject': '로컬 프로젝트 추가', 'directoryExplorerDialog.actions.cloneRepository': '저장소 복제', 'directoryExplorerDialog.actions.cloneAndAdd': '복제하고 추가', @@ -1844,6 +1845,7 @@ export const dict: Record = { 'directoryExplorerDialog.browse.parentDirectory': '상위 디렉터리', 'directoryExplorerDialog.browse.addedBadge': '추가됨', 'directoryExplorerDialog.browse.quickAdd': '추가', + 'directoryExplorerDialog.browse.selectForAdd': '추가할 항목 선택', 'directoryExplorerDialog.footer.navigate': '탐색', 'directoryExplorerDialog.footer.select': '선택', 'directoryExplorerDialog.footer.add': '추가', @@ -1852,6 +1854,7 @@ export const dict: Record = { 'directoryExplorerDialog.toast.desktopDeniedAccess': '데스크톱에서 디렉터리 접근이 거부되었습니다.', 'directoryExplorerDialog.toast.failedToOpenDirectory': '디렉터리를 열지 못했습니다', 'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': '데스크톱에서 파일 접근 권한을 부여하지 못했습니다.', + 'directoryExplorerDialog.toast.addedProjects': '프로젝트 {count}개 추가됨', 'directoryExplorerDialog.toast.failedToAddProject': '프로젝트 추가 실패', 'directoryExplorerDialog.toast.cloneUrlRequired': '복제하기 전에 저장소 URL을 입력하세요.', 'directoryExplorerDialog.toast.selectValidDirectoryPath': '유효한 디렉터리 경로를 선택하세요.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 9e22cbb1..c299e3b3 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1890,6 +1890,7 @@ export const dict: Record = { "diffView.scope.selectorAria": "Wybierz tryb zmian", 'diffView.summary.changedFilesSingle': 'Zmieniono {count} plik', 'directoryExplorerDialog.actions.addProject': 'Dodaj projekt', + 'directoryExplorerDialog.actions.addSelected': 'Dodaj zaznaczone', 'directoryExplorerDialog.actions.addLocalProject': 'Dodaj projekt lokalny', 'directoryExplorerDialog.actions.adding': 'Dodawanie...', 'directoryExplorerDialog.actions.alreadyAdded': 'Już dodano', @@ -1901,6 +1902,7 @@ export const dict: Record = { 'directoryExplorerDialog.actions.openingFinder': 'Otwieranie...', 'directoryExplorerDialog.browse.addedBadge': 'Dodano', 'directoryExplorerDialog.browse.quickAdd': 'Dodaj', + 'directoryExplorerDialog.browse.selectForAdd': 'Zaznacz do dodania', 'directoryExplorerDialog.browse.directories': 'Katalogi', 'directoryExplorerDialog.browse.empty': 'Brak pasujących katalogów.', 'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber potrzebuje dostępu do tego folderu.', @@ -1919,6 +1921,7 @@ export const dict: Record = { 'directoryExplorerDialog.title': 'Dodaj katalog projektu', 'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'Aplikacja desktopowa nie mogła przyznać dostępu do pliku.', 'directoryExplorerDialog.toast.desktopDeniedAccess': 'Aplikacja desktopowa odmówiła dostępu do katalogu.', + 'directoryExplorerDialog.toast.addedProjects': 'Dodano {count} projektów', 'directoryExplorerDialog.toast.failedToAddProject': 'Nie udało się dodać projektu', 'directoryExplorerDialog.toast.cloneUrlRequired': 'Wpisz URL repozytorium przed klonowaniem.', 'directoryExplorerDialog.toast.failedToOpenDirectory': 'Nie udało się otworzyć katalogu', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 34438f28..9674b941 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1803,6 +1803,7 @@ export const dict: Record = { "directoryExplorerDialog.actions.openInFinder": "Abrir no Finder", "directoryExplorerDialog.actions.adding": "Adicionando...", "directoryExplorerDialog.actions.addProject": "Adicionar projeto", + "directoryExplorerDialog.actions.addSelected": "Adicionar selecionados", "directoryExplorerDialog.actions.addLocalProject": "Adicionar projeto local", "directoryExplorerDialog.actions.cloneRepository": "Clonar repositório", "directoryExplorerDialog.actions.cloneAndAdd": "Clonar e adicionar", @@ -1820,6 +1821,7 @@ export const dict: Record = { "directoryExplorerDialog.browse.parentDirectory": "Diretório pai", "directoryExplorerDialog.browse.addedBadge": "Adicionado", "directoryExplorerDialog.browse.quickAdd": "Adicionar", + "directoryExplorerDialog.browse.selectForAdd": "Selecionar para adicionar", "directoryExplorerDialog.footer.navigate": "Navegar", "directoryExplorerDialog.footer.select": "Selecionar", "directoryExplorerDialog.footer.add": "Adicionar", @@ -1828,6 +1830,7 @@ export const dict: Record = { "directoryExplorerDialog.toast.desktopDeniedAccess": "O desktop negou o acesso ao diretório.", "directoryExplorerDialog.toast.failedToOpenDirectory": "Não foi possível abrir o diretório", "directoryExplorerDialog.toast.desktopCouldNotGrantAccess": "O desktop não pôde conceder acesso ao arquivo.", + "directoryExplorerDialog.toast.addedProjects": "Foram adicionados {count} projeto(s)", "directoryExplorerDialog.toast.failedToAddProject": "Não foi possível adicionar o projeto", "directoryExplorerDialog.toast.cloneUrlRequired": "Insira uma URL de repositório antes de clonar.", "directoryExplorerDialog.toast.selectValidDirectoryPath": "Selecione um caminho de diretório válido.", diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index dee11891..f51e8839 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -1787,6 +1787,7 @@ export const dict = { 'directoryExplorerDialog.actions.openInFinder': 'Finder\'da aç', 'directoryExplorerDialog.actions.adding': 'Ekleniyor...', 'directoryExplorerDialog.actions.addProject': 'Proje ekle', + 'directoryExplorerDialog.actions.addSelected': 'Seçilenleri ekle', 'directoryExplorerDialog.actions.addLocalProject': 'Yerel proje ekle', 'directoryExplorerDialog.actions.cloneRepository': 'Depoyu klonla', 'directoryExplorerDialog.actions.cloneAndAdd': 'Klonla ve ekle', @@ -1803,6 +1804,7 @@ export const dict = { 'directoryExplorerDialog.browse.retry': 'Yeniden dene', 'directoryExplorerDialog.browse.parentDirectory': 'Üst dizin', 'directoryExplorerDialog.browse.addedBadge': 'Eklendi', + 'directoryExplorerDialog.browse.selectForAdd': 'Eklemek için seç', 'directoryExplorerDialog.browse.quickAdd': 'Ekle', 'directoryExplorerDialog.footer.navigate': 'Gezin', 'directoryExplorerDialog.footer.select': 'Seç', @@ -1812,6 +1814,7 @@ export const dict = { 'directoryExplorerDialog.toast.desktopDeniedAccess': 'Masaüstü uygulaması dizin erişimini reddetti.', 'directoryExplorerDialog.toast.failedToOpenDirectory': 'Dizin açılamadı', 'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'Masaüstü uygulaması dosya erişimi veremedi.', + 'directoryExplorerDialog.toast.addedProjects': '{count} proje eklendi', 'directoryExplorerDialog.toast.failedToAddProject': 'Proje eklenemedi', 'directoryExplorerDialog.toast.cloneUrlRequired': 'Klonlamadan önce bir depo URL\'si girin.', 'directoryExplorerDialog.toast.selectValidDirectoryPath': 'Geçerli bir dizin yolu seçin.', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 8f384af4..40e3ba5e 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1803,6 +1803,7 @@ export const dict: Record = { "directoryExplorerDialog.actions.openInFinder": "Відкрити у Finder", "directoryExplorerDialog.actions.adding": "Додавання...", "directoryExplorerDialog.actions.addProject": "Додати проєкт", + "directoryExplorerDialog.actions.addSelected": "Додати вибрані", "directoryExplorerDialog.actions.addLocalProject": "Додати локальний проєкт", "directoryExplorerDialog.actions.cloneRepository": "Клонувати репозиторій", "directoryExplorerDialog.actions.cloneAndAdd": "Клонувати й додати", @@ -1820,6 +1821,7 @@ export const dict: Record = { "directoryExplorerDialog.browse.parentDirectory": "Батьківський каталог", "directoryExplorerDialog.browse.addedBadge": "Додано", "directoryExplorerDialog.browse.quickAdd": "Додати", + "directoryExplorerDialog.browse.selectForAdd": "Вибрати для додавання", "directoryExplorerDialog.footer.navigate": "Навігація", "directoryExplorerDialog.footer.select": "Вибрати", "directoryExplorerDialog.footer.add": "Додати", @@ -1828,6 +1830,7 @@ export const dict: Record = { "directoryExplorerDialog.toast.desktopDeniedAccess": "Десктопному застосунку заборонено доступ до каталогу.", "directoryExplorerDialog.toast.failedToOpenDirectory": "Не вдалося відкрити каталог", "directoryExplorerDialog.toast.desktopCouldNotGrantAccess": "Десктопний застосунок не зміг надати доступ до файлу.", + "directoryExplorerDialog.toast.addedProjects": "Додано {count} проєкт(и)", "directoryExplorerDialog.toast.failedToAddProject": "Не вдалося додати проєкт", "directoryExplorerDialog.toast.cloneUrlRequired": "Введіть URL репозиторію перед клонуванням.", "directoryExplorerDialog.toast.selectValidDirectoryPath": "Виберіть правильний шлях до каталогу.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 54375afc..89168582 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1791,6 +1791,7 @@ export const dict: Record = { 'directoryExplorerDialog.actions.openInFinder': '在 Finder 中打开', 'directoryExplorerDialog.actions.adding': '添加中...', 'directoryExplorerDialog.actions.addProject': '添加项目', + 'directoryExplorerDialog.actions.addSelected': '添加所选项目', 'directoryExplorerDialog.actions.addLocalProject': '添加本地项目', 'directoryExplorerDialog.actions.cloneRepository': '克隆仓库', 'directoryExplorerDialog.actions.cloneAndAdd': '克隆并添加', @@ -1808,6 +1809,7 @@ export const dict: Record = { 'directoryExplorerDialog.browse.parentDirectory': '上级目录', 'directoryExplorerDialog.browse.addedBadge': '已添加', 'directoryExplorerDialog.browse.quickAdd': '添加', + 'directoryExplorerDialog.browse.selectForAdd': '选择以添加', 'directoryExplorerDialog.footer.navigate': '导航', 'directoryExplorerDialog.footer.select': '选择', 'directoryExplorerDialog.footer.add': '添加', @@ -1816,6 +1818,7 @@ export const dict: Record = { 'directoryExplorerDialog.toast.desktopDeniedAccess': '桌面端拒绝了目录访问。', 'directoryExplorerDialog.toast.failedToOpenDirectory': '打开目录失败', 'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': '桌面端无法授予文件访问权限。', + 'directoryExplorerDialog.toast.addedProjects': '已添加 {count} 个项目', 'directoryExplorerDialog.toast.failedToAddProject': '添加项目失败', 'directoryExplorerDialog.toast.cloneUrlRequired': '克隆前请输入仓库 URL。', 'directoryExplorerDialog.toast.selectValidDirectoryPath': '请选择有效的目录路径。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 8e9b2234..3b495f1e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1795,6 +1795,7 @@ export const dict: Record = { 'directoryExplorerDialog.actions.openInFinder': '在 Finder 中開啟', 'directoryExplorerDialog.actions.adding': '新增中...', 'directoryExplorerDialog.actions.addProject': '新增專案', + 'directoryExplorerDialog.actions.addSelected': '新增所選項目', 'directoryExplorerDialog.actions.addLocalProject': '新增本地專案', 'directoryExplorerDialog.actions.cloneRepository': '複製儲存庫', 'directoryExplorerDialog.actions.cloneAndAdd': '複製並新增', @@ -1812,6 +1813,7 @@ export const dict: Record = { 'directoryExplorerDialog.browse.parentDirectory': '上層目錄', 'directoryExplorerDialog.browse.addedBadge': '已新增', 'directoryExplorerDialog.browse.quickAdd': '添加', + 'directoryExplorerDialog.browse.selectForAdd': '選取以新增', 'directoryExplorerDialog.footer.navigate': '導覽', 'directoryExplorerDialog.footer.select': '選擇', 'directoryExplorerDialog.footer.add': '新增', @@ -1820,6 +1822,7 @@ export const dict: Record = { 'directoryExplorerDialog.toast.desktopDeniedAccess': '桌面端拒絕了目錄存取。', 'directoryExplorerDialog.toast.failedToOpenDirectory': '開啟目錄失敗', 'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': '桌面端無法授予檔案存取權限。', + 'directoryExplorerDialog.toast.addedProjects': '已新增 {count} 個專案', 'directoryExplorerDialog.toast.failedToAddProject': '新增專案失敗', 'directoryExplorerDialog.toast.cloneUrlRequired': '複製前請輸入儲存庫 URL。', 'directoryExplorerDialog.toast.selectValidDirectoryPath': '請選擇有效的目錄路徑。', diff --git a/packages/ui/src/stores/useProjectsStore.test.ts b/packages/ui/src/stores/useProjectsStore.test.ts index 6e45dd38..e2a77d8a 100644 --- a/packages/ui/src/stores/useProjectsStore.test.ts +++ b/packages/ui/src/stores/useProjectsStore.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import type { ProjectEntry } from "@/lib/api/types" import type { DesktopSettings } from "@/lib/desktop" import { useProjectsStore } from "./useProjectsStore" +import { useDirectoryStore } from "./useDirectoryStore" describe("useProjectsStore settings synchronization", () => { test("treats a successful empty project snapshot as authoritative", () => { @@ -119,3 +120,53 @@ describe("useProjectsStore default model and thinking level", () => { expect(project?.defaultVariant).toBe(undefined) }) }) + +describe("useProjectsStore.addProjects", () => { + const resetProjects = () => { + useProjectsStore.setState({ + projects: [], + activeProjectId: null, + manualProjectOrder: [], + }) + } + + test("adds multiple new projects in one update and activates the first", async () => { + resetProjects() + + const added = await useProjectsStore.getState().addProjects(["/one", "/two", "/three"]) + + expect(added).toHaveLength(3) + expect(useProjectsStore.getState().projects.map((p) => p.path)).toEqual(["/one", "/two", "/three"]) + expect(useProjectsStore.getState().activeProjectId).toBe(added[0].id) + expect(added[0].addedAt).toBe(added[1].addedAt) + }) + + test("skips already-added paths and duplicates within the batch", async () => { + resetProjects() + await useProjectsStore.getState().addProjects(["/one"]) + + const added = await useProjectsStore.getState().addProjects(["/one", "/two", "/two", "/one"]) + + expect(added).toHaveLength(1) + expect(added[0].path).toBe("/two") + expect(useProjectsStore.getState().projects.map((p) => p.path)).toEqual(["/one", "/two"]) + }) + + test("skips invalid paths and returns an empty array when nothing is addable", async () => { + resetProjects() + + const added = await useProjectsStore.getState().addProjects(["", " ", 42 as unknown as string]) + + expect(added).toEqual([]) + expect(useProjectsStore.getState().projects).toEqual([]) + }) + + test("normalizes paths (trailing separators, backslashes, tilde expansion)", async () => { + resetProjects() + + const added = await useProjectsStore.getState().addProjects(["/repo/", "C:\\repo", "~/project"]) + + const home = useDirectoryStore.getState().homeDirectory; + expect(added.map((p) => p.path)).toEqual(["/repo", "C:/repo", home ? `${home}/project` : "~/project"]) + }) +}) diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index dabf4ddd..c3697671 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -50,6 +50,7 @@ interface ProjectsStore { manualProjectOrder: string[]; addProject: (path: string, options?: { label?: string; id?: string }) => Promise; + addProjects: (paths: string[]) => Promise; removeProject: (id: string) => void; setActiveProject: (id: string) => void; setActiveProjectIdOnly: (id: string) => void; @@ -653,6 +654,69 @@ export const useProjectsStore = create()( return entry; }, + addProjects: async (paths: string[]) => { + if (isVSCodeProjectsRuntime) { + // VS Code paths are added via runtimeApis.vscode.addWorkspaceFolder, + // which is reached only by addProject. Iterate so valid selections + // succeed instead of silently returning []. Dedupe by path so the + // returned array mirrors the non-VS Code contract. + const added: ProjectEntry[] = []; + const seen = new Set(); + for (const path of paths) { + if (seen.has(path)) continue; + seen.add(path); + const project = await get().addProject(path); + if (project) { + added.push(project); + } + } + return added; + } + const current = get(); + const existingPaths = new Set(current.projects.map((project) => project.path)); + const now = Date.now(); + const entries: ProjectEntry[] = []; + const seenPaths = new Set(); + + for (const rawPath of paths) { + const validation = get().validateProjectPath(rawPath); + if (!validation.ok || !validation.normalizedPath) { + continue; + } + const normalizedPath = validation.normalizedPath; + if (existingPaths.has(normalizedPath) || seenPaths.has(normalizedPath)) { + continue; + } + seenPaths.add(normalizedPath); + entries.push({ + id: createProjectIdFromPath(normalizedPath), + path: normalizedPath, + label: deriveProjectLabel(normalizedPath), + color: pickAutoColor([...current.projects, ...entries]), + addedAt: now, + lastOpenedAt: now, + }); + } + + if (entries.length === 0) { + return []; + } + + const nextProjects = [...current.projects, ...entries]; + set({ projects: nextProjects }); + + if (streamDebugEnabled()) { + console.info('[ProjectsStore] Added projects', entries); + } + + // Mirror addProject: the first newly added project becomes active. + get().setActiveProject(entries[0].id); + for (const entry of entries) { + void get().discoverProjectIcon(entry.id); + } + return entries; + }, + removeProject: (id: string) => { if (isVSCodeProjectsRuntime) { return; diff --git a/packages/ui/src/stores/useProjectsStore.vscodeAddProject.test.ts b/packages/ui/src/stores/useProjectsStore.vscodeAddProject.test.ts index eb32663a..fc33684d 100644 --- a/packages/ui/src/stores/useProjectsStore.vscodeAddProject.test.ts +++ b/packages/ui/src/stores/useProjectsStore.vscodeAddProject.test.ts @@ -133,4 +133,44 @@ describe('issue #2582: addProject in the VS Code runtime', () => { expect(added).toBeNull(); expect(useProjectsStore.getState().projects.find((p) => p.path === '/other/path')).toBeFalsy(); }); + + test('addProjects iterates addProject in the VS Code runtime so valid selections succeed', async () => { + // Regression: addProjects used to return [] unconditionally for the + // VS Code runtime, which made the batch-add path toast "Failed to + // add project" even for valid selections. The fix calls + // addWorkspaceFolder per path; we assert the host is invoked once + // per selection (not skipped) and that any successful add returns + // a non-null entry. + const added = await useProjectsStore.getState().addProjects([ + '/home/user/project-a', + '/home/user/project-b', + ]); + + expect(addWorkspaceFolderCalls).toEqual([ + '/home/user/project-a', + '/home/user/project-b', + ]); + // The mock's addWorkspaceFolder returns the second entry keyed by + // `path`, so project-a lands; project-b is not reflected in + // projects because the mock's hardcoded return array doesn't + // include it. The point of the test is the call sequence, not the + // final projects state (covered by the dedicated addProject tests). + expect(added.length).toBeGreaterThanOrEqual(1); + }); + + test('addProjects dedupes paths within a single batch in the VS Code runtime', async () => { + // A path repeated within one batch must hit the extension host once, + // not twice — mirrors the non-VS Code contract (seenPaths Set). + addWorkspaceFolderCalls.length = 0; + await useProjectsStore.getState().addProjects([ + '/home/user/project-a', + '/home/user/project-a', + '/home/user/project-b', + ]); + + expect(addWorkspaceFolderCalls).toEqual([ + '/home/user/project-a', + '/home/user/project-b', + ]); + }); });