From 04c37d32a568c89eb868a0f77b8bd623f8aca16f Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 10:26:30 +0300 Subject: [PATCH 1/9] feat(projects): support adding multiple projects at once in the directory picker Add a multi-select mode to the "Add project directory" dialog: each directory row gets a select toggle (checkbox icon, Space toggles the highlighted row), and the primary action becomes "Add selected" and registers every selected directory in one store update. Selections apply to the currently browsed directory and reset on navigation, dialog open, and clone-mode entry. Clone mode keeps its single-target flow. Add addProjects() to useProjectsStore: validates, normalizes, and dedups paths (already-added or duplicated), creates entries in a single state update and single persist, activates the first newly added project, and discovers icons for each entry. Mirrors addProject semantics for the single entry. Refs OPE-142 --- CHANGELOG.md | 1 + .../session/DirectoryExplorerDialog.tsx | 134 ++++++++++++++---- packages/ui/src/lib/i18n/messages/de.ts | 2 + packages/ui/src/lib/i18n/messages/en.ts | 2 + packages/ui/src/lib/i18n/messages/es.ts | 2 + packages/ui/src/lib/i18n/messages/fr.ts | 2 + packages/ui/src/lib/i18n/messages/ja.ts | 2 + packages/ui/src/lib/i18n/messages/ko.ts | 2 + packages/ui/src/lib/i18n/messages/pl.ts | 2 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 + packages/ui/src/lib/i18n/messages/uk.ts | 2 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 + .../ui/src/stores/useProjectsStore.test.ts | 51 +++++++ packages/ui/src/stores/useProjectsStore.ts | 54 +++++++ 15 files changed, 234 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4cc429..0203c741 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - Usage: GitHub Copilot now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss). - Settings: fixed the Cloudflare Tunnel download link shown when cloudflared is not installed (thanks to @AyoubAchour). - Git: picking a remote branch such as `origin/main` in the branch selector now switches you to that branch instead of leaving the repository on a detached `HEAD` with no branch name. +- Projects: the "Add project directory" picker now accepts multiple selections at once — each row has a checkbox (or press Space on the highlighted row) and the primary action adds every selected directory in a single store update, with per-entry validation, deduplication, and one persistence write (thanks to @herjarsa). - Desktop: "Restart to Update" no longer looks dead when the update cannot be installed — the update window now shows the reason, including when the running copy was not installed from an official signed release, and the button stays available to retry. ## [1.21.0] - 2026-08-26 diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 90f4f370..1eb1c51a 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,14 +454,21 @@ 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); try { - const shouldCreateSelection = !isCloneMode && shouldCreateTarget && normalizeDirectoryPath(target) === normalizeDirectoryPath(targetPath); if (isCloneMode) { const remoteUrl = cloneRemoteUrl.trim(); if (!remoteUrl) { @@ -451,6 +483,20 @@ export const DirectoryExplorerDialog: React.FC = ( selectedTarget = result.path; } else if (shouldCreateSelection) { await opencodeClient.createDirectory(target, { asProject: true }); + } else if (selectionToAdd.length > 0) { + const added = addProjects(selectionToAdd); + if (added.length === 0) { + toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { + description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'), + }); + return; + } + setSelectedPaths([]); + handleClose(); + return; + } else if (shouldCreateTarget && normalizeDirectoryPath(target) === normalizeDirectoryPath(targetPath)) { + await opencodeClient.createDirectory(target); + } } const project = await addProject(selectedTarget); if (!project) { @@ -467,7 +513,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)); @@ -529,6 +575,13 @@ export const DirectoryExplorerDialog: React.FC = ( setHighlightedIndex((index) => Math.max(0, index - 1)); return; } + if (event.key === ' ') { + event.preventDefault(); + if (highlightedRow && highlightedRow.type === 'directory' && !highlightedRow.disabled) { + togglePathSelection(highlightedRow.path); + } + return; + } if (event.key === 'Enter') { event.preventDefault(); if (isPrimaryModifierPressed(event)) { @@ -544,7 +597,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 +807,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 fa4074d6..5f35569d 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1612,6 +1612,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', @@ -1629,6 +1630,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', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 6279f4fd..740994e7 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1813,6 +1813,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', @@ -1830,6 +1831,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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 38eff40c..2b6c39cd 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1791,6 +1791,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", @@ -1808,6 +1809,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", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index d77d52ce..2d1489fc 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1571,6 +1571,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', @@ -1588,6 +1589,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', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index a84c550b..7aabdc4c 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1809,6 +1809,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': 'クローンして追加', @@ -1826,6 +1827,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': '追加', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 1b9efd89..f96567d5 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1815,6 +1815,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': '복제하고 추가', @@ -1832,6 +1833,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': '추가', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index bdf8e565..3dd3046a 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1880,6 +1880,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', @@ -1891,6 +1892,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.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index a3da93ae..cc95fbd8 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1791,6 +1791,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", @@ -1808,6 +1809,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", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 2e5e3272..f849cebf 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.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": "Додати", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 1019c0fe..d6b062c5 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1779,6 +1779,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': '克隆并添加', @@ -1796,6 +1797,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': '添加', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 887bfedc..1fa68075 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1783,6 +1783,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': '複製並新增', @@ -1800,6 +1801,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': '新增', diff --git a/packages/ui/src/stores/useProjectsStore.test.ts b/packages/ui/src/stores/useProjectsStore.test.ts index 6e45dd38..25a09f96 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", () => { + resetProjects() + + const added = 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", () => { + resetProjects() + useProjectsStore.getState().addProjects(["/one"]) + + const added = 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", () => { + resetProjects() + + const added = useProjectsStore.getState().addProjects(["", " ", 42 as unknown as string]) + + expect(added).toEqual([]) + expect(useProjectsStore.getState().projects).toEqual([]) + }) + + test("normalizes paths (trailing separators, backslashes, tilde expansion)", () => { + resetProjects() + + const added = 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..83f618b0 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -49,7 +49,12 @@ interface ProjectsStore { activeProjectId: string | null; manualProjectOrder: string[]; +<<<<<<< HEAD addProject: (path: string, options?: { label?: string; id?: string }) => Promise; +======= + addProject: (path: string, options?: { label?: string; id?: string }) => ProjectEntry | null; + addProjects: (paths: string[]) => ProjectEntry[]; +>>>>>>> e7506d349 (feat(projects): support adding multiple projects at once in the directory picker) removeProject: (id: string) => void; setActiveProject: (id: string) => void; setActiveProjectIdOnly: (id: string) => void; @@ -653,6 +658,55 @@ export const useProjectsStore = create()( return entry; }, + addProjects: (paths: string[]) => { + if (isVSCodeProjectsRuntime) { + return []; + } + 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; From b56e60462e5bccb7470e3903ad0cf63c8f8fb917 Mon Sep 17 00:00:00 2001 From: herjarsa Date: Thu, 13 Aug 2026 17:12:02 +0200 Subject: [PATCH 2/9] fix(ui): resolve merge conflict in DirectoryExplorerDialog batch add --- packages/ui/src/components/session/DirectoryExplorerDialog.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 1eb1c51a..fa7e61cc 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -497,7 +497,6 @@ export const DirectoryExplorerDialog: React.FC = ( } else if (shouldCreateTarget && normalizeDirectoryPath(target) === normalizeDirectoryPath(targetPath)) { await opencodeClient.createDirectory(target); } - } const project = await addProject(selectedTarget); if (!project) { toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { From 223849261a461c67fff0c03b5053f16698d492ba Mon Sep 17 00:00:00 2001 From: herjarsa Date: Fri, 28 Aug 2026 09:52:17 +0200 Subject: [PATCH 3/9] fix(ui): stop click propagation on the multi-select checkbox The checkbox button calls togglePathSelection() on click but does not stop event propagation, so the click bubbles to the parent row's onClick which calls executeRow() -> browseToEntry(). With a mouse, every checkbox click would (a) toggle the selection, (b) navigate into the directory, and (c) the navigation effect would clear selectedPaths. The primary interaction of the multi-select feature was unusable. The existing handleQuickAdd helper avoids this exact bug by calling event.stopPropagation() inside its onClick handler. Apply the same pattern to the new togglePathSelection onClick. Refs openchamber-bot review on #2877. --- packages/ui/src/components/session/DirectoryExplorerDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index fa7e61cc..800e66e5 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -740,7 +740,7 @@ export const DirectoryExplorerDialog: React.FC = (