feat(projects): add multiple projects at once in the directory picker (#2877)

feat(projects): add multiple projects at once in the directory picker
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 00:50:32 +03:00
committed by GitHub
16 changed files with 308 additions and 27 deletions
@@ -151,6 +151,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
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<DirectoryExplorerDialogProps> = (
const [cloneRemoteUrl, setCloneRemoteUrl] = React.useState('');
const [selectedGitIdentityId, setSelectedGitIdentityId] = React.useState<string | null>(null);
const [showHidden, setShowHidden] = React.useState(false);
const [selectedPaths, setSelectedPaths] = React.useState<string[]>([]);
const explorerRootDirectory = dialogHomeDirectory || homeDirectory;
@@ -197,6 +199,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
setCloneRemoteUrl('');
setSelectedGitIdentityId(null);
setShowHidden(false);
setSelectedPaths([]);
requestAnimationFrame(() => focusPathInput(inputRef.current));
let cancelled = false;
@@ -330,6 +333,27 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
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<DirectoryExplorerDialogProps> = (
);
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<DirectoryExplorerDialogProps> = (
}, [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<DirectoryExplorerDialogProps> = (
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<DirectoryExplorerDialogProps> = (
} 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<DirectoryExplorerDialogProps> = (
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<DirectoryExplorerDialogProps> = (
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<DirectoryExplorerDialogProps> = (
event.preventDefault();
handleClose();
}
}, [executeRow, finalizeSelection, handleClose, hasHighlightedBrowseItem, highlightedRow, query, rows.length, targetPath]);
}, [executeRow, finalizeSelection, handleClose, hasHighlightedBrowseItem, highlightedRow, query, rows.length, targetPath, togglePathSelection]);
const showHiddenToggle = (
<button
@@ -684,15 +749,31 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
{t('directoryExplorerDialog.browse.addedBadge')}
</span>
) : row.type === 'directory' ? (
<button
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => handleQuickAdd(event, row.path)}
className="flex-shrink-0 rounded-full p-1 text-muted-foreground transition-colors hover:bg-interactive-hover/60 hover:text-foreground"
title={t('directoryExplorerDialog.browse.quickAdd')}
>
<Icon name="add" className="h-3.5 w-3.5" />
</button>
<>
<button
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); togglePathSelection(row.path); }}
title={t('directoryExplorerDialog.browse.selectForAdd')}
aria-label={t('directoryExplorerDialog.browse.selectForAdd')}
aria-pressed={selectedPaths.includes(row.path)}
className="flex-shrink-0 rounded p-0.5 text-muted-foreground transition-colors hover:bg-interactive-hover/60 hover:text-foreground"
>
<Icon
name={selectedPaths.includes(row.path) ? 'checkbox' : 'checkbox-blank'}
className="h-4 w-4"
/>
</button>
<button
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => handleQuickAdd(event, row.path)}
className="flex-shrink-0 rounded-full p-1 text-muted-foreground transition-colors hover:bg-interactive-hover/60 hover:text-foreground"
title={t('directoryExplorerDialog.browse.quickAdd')}
>
<Icon name="add" className="h-3.5 w-3.5" />
</button>
</>
) : null}
</button>
);
@@ -738,7 +819,16 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
{isOpeningFinder ? t('directoryExplorerDialog.actions.openingFinder') : t('directoryExplorerDialog.actions.openInFinder')}
</Button>
) : null}
<Button variant="ghost" size="xs" onClick={() => setIsCloneMode((value) => !value)} disabled={isConfirming || isOpeningFinder} className={cn(isMobile && 'flex-1')}>
<Button
variant="ghost"
size="xs"
onClick={() => {
setIsCloneMode((value) => !value);
setSelectedPaths([]);
}}
disabled={isConfirming || isOpeningFinder}
className={cn(isMobile && 'flex-1')}
>
{isCloneMode ? t('directoryExplorerDialog.actions.addLocalProject') : t('directoryExplorerDialog.actions.cloneRepository')}
</Button>
{isMobile ? (
+3
View File
@@ -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.',
+3
View File
@@ -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.',
+3
View File
@@ -1803,6 +1803,7 @@ export const dict: Record<I18nKey, string> = {
"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<I18nKey, string> = {
"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<I18nKey, string> = {
"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.",
+3
View File
@@ -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.',
+3
View File
@@ -1821,6 +1821,7 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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<I18nKey, string> = {
'directoryExplorerDialog.toast.desktopDeniedAccess': 'デスクトップがディレクトリアクセスを拒否しました。',
'directoryExplorerDialog.toast.failedToOpenDirectory': 'ディレクトリを開けませんでした',
'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'デスクトップがファイルアクセスを許可できませんでした。',
'directoryExplorerDialog.toast.addedProjects': '{count}件のプロジェクトを追加しました',
'directoryExplorerDialog.toast.failedToAddProject': 'プロジェクトの追加に失敗しました',
'directoryExplorerDialog.toast.cloneUrlRequired': 'クローンする前にリポジトリURLを入力してください。',
'directoryExplorerDialog.toast.selectValidDirectoryPath': '有効なディレクトリパスを選択してください。',
+3
View File
@@ -1827,6 +1827,7 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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<I18nKey, string> = {
'directoryExplorerDialog.toast.desktopDeniedAccess': '데스크톱에서 디렉터리 접근이 거부되었습니다.',
'directoryExplorerDialog.toast.failedToOpenDirectory': '디렉터리를 열지 못했습니다',
'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': '데스크톱에서 파일 접근 권한을 부여하지 못했습니다.',
'directoryExplorerDialog.toast.addedProjects': '프로젝트 {count}개 추가됨',
'directoryExplorerDialog.toast.failedToAddProject': '프로젝트 추가 실패',
'directoryExplorerDialog.toast.cloneUrlRequired': '복제하기 전에 저장소 URL을 입력하세요.',
'directoryExplorerDialog.toast.selectValidDirectoryPath': '유효한 디렉터리 경로를 선택하세요.',
+3
View File
@@ -1890,6 +1890,7 @@ export const dict: Record<I18nKey, string> = {
"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<I18nKey, string> = {
'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<I18nKey, string> = {
'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',
@@ -1803,6 +1803,7 @@ export const dict: Record<I18nKey, string> = {
"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<I18nKey, string> = {
"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<I18nKey, string> = {
"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.",
+3
View File
@@ -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.',
+3
View File
@@ -1803,6 +1803,7 @@ export const dict: Record<I18nKey, string> = {
"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<I18nKey, string> = {
"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<I18nKey, string> = {
"directoryExplorerDialog.toast.desktopDeniedAccess": "Десктопному застосунку заборонено доступ до каталогу.",
"directoryExplorerDialog.toast.failedToOpenDirectory": "Не вдалося відкрити каталог",
"directoryExplorerDialog.toast.desktopCouldNotGrantAccess": "Десктопний застосунок не зміг надати доступ до файлу.",
"directoryExplorerDialog.toast.addedProjects": "Додано {count} проєкт(и)",
"directoryExplorerDialog.toast.failedToAddProject": "Не вдалося додати проєкт",
"directoryExplorerDialog.toast.cloneUrlRequired": "Введіть URL репозиторію перед клонуванням.",
"directoryExplorerDialog.toast.selectValidDirectoryPath": "Виберіть правильний шлях до каталогу.",
@@ -1791,6 +1791,7 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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<I18nKey, string> = {
'directoryExplorerDialog.toast.desktopDeniedAccess': '桌面端拒绝了目录访问。',
'directoryExplorerDialog.toast.failedToOpenDirectory': '打开目录失败',
'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': '桌面端无法授予文件访问权限。',
'directoryExplorerDialog.toast.addedProjects': '已添加 {count} 个项目',
'directoryExplorerDialog.toast.failedToAddProject': '添加项目失败',
'directoryExplorerDialog.toast.cloneUrlRequired': '克隆前请输入仓库 URL。',
'directoryExplorerDialog.toast.selectValidDirectoryPath': '请选择有效的目录路径。',
@@ -1795,6 +1795,7 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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<I18nKey, string> = {
'directoryExplorerDialog.toast.desktopDeniedAccess': '桌面端拒絕了目錄存取。',
'directoryExplorerDialog.toast.failedToOpenDirectory': '開啟目錄失敗',
'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': '桌面端無法授予檔案存取權限。',
'directoryExplorerDialog.toast.addedProjects': '已新增 {count} 個專案',
'directoryExplorerDialog.toast.failedToAddProject': '新增專案失敗',
'directoryExplorerDialog.toast.cloneUrlRequired': '複製前請輸入儲存庫 URL。',
'directoryExplorerDialog.toast.selectValidDirectoryPath': '請選擇有效的目錄路徑。',
@@ -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"])
})
})
@@ -50,6 +50,7 @@ interface ProjectsStore {
manualProjectOrder: string[];
addProject: (path: string, options?: { label?: string; id?: string }) => Promise<ProjectEntry | null>;
addProjects: (paths: string[]) => Promise<ProjectEntry[]>;
removeProject: (id: string) => void;
setActiveProject: (id: string) => void;
setActiveProjectIdOnly: (id: string) => void;
@@ -653,6 +654,69 @@ export const useProjectsStore = create<ProjectsStore>()(
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<string>();
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<string>();
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;
@@ -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',
]);
});
});