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;