feat: Add multi-folder local project picker (#5406)

* feat: add multi-select to local project folder picker

- Replace single-folder `pickFolder` IPC with multi-select `pickFolders`
- Refactor add-local-path flow to process multiple folders in sequence,
  deferring the git-ready handoff to the first successfully added repo
- Skip non-git folders silently during batch adds and surface a toast
  when any were skipped, rather than blocking the entire import on the
  nested-repo review UI

* fix: address multi-folder picker review feedback

* Skip nested repository review on progress during batch folder adds

Avoid initiating the nested-repository review flow during multi-folder
batch adds. This ensures the batch folder flow can continue processing
git folders without interruption.

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Trevin Chow 2026-06-15 20:54:03 -07:00 committed by GitHub
parent 52f2a32e62
commit 2a42d46719
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 436 additions and 27 deletions

View File

@ -0,0 +1,104 @@
import { join, sep } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, removeHandlerMock, showOpenDialogMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
showOpenDialogMock: vi.fn()
}))
vi.mock('electron', () => ({
dialog: { showOpenDialog: showOpenDialogMock },
ipcMain: {
handle: handleMock,
removeHandler: removeHandlerMock
}
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: vi.fn(),
gitSpawn: vi.fn()
}))
vi.mock('../git/repo', () => ({
isGitRepo: vi.fn(),
getGitUsername: vi.fn(),
getRepoName: vi.fn(),
getBaseRefDefault: vi.fn(),
searchBaseRefs: vi.fn()
}))
vi.mock('./filesystem-auth', () => ({
invalidateAuthorizedRootsCache: vi.fn()
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: vi.fn()
}))
vi.mock('./ssh', () => ({
getActiveMultiplexer: vi.fn()
}))
import { registerRepoHandlers } from './repos'
describe('repos folder pickers', () => {
const handlers = new Map<string, (event: unknown, args: unknown) => unknown>()
const mockWindow = {
isDestroyed: () => false,
webContents: { send: vi.fn() }
}
const mockStore = {
getRepos: vi.fn().mockReturnValue([]),
addRepo: vi.fn(),
removeProject: vi.fn(),
getRepo: vi.fn(),
updateRepo: vi.fn()
}
const callPickFolders = (): Promise<string[]> => {
const handler = handlers.get('repos:pickFolders')
if (!handler) {
throw new Error('repos:pickFolders handler was never registered')
}
return handler(null, undefined) as Promise<string[]>
}
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => {
handlers.set(channel, handler as (event: unknown, args: unknown) => unknown)
})
removeHandlerMock.mockReset()
showOpenDialogMock.mockReset()
registerRepoHandlers(mockWindow as never, mockStore as never)
})
it('registers the multi-folder picker with handler cleanup', () => {
expect(handlers.has('repos:pickFolders')).toBe(true)
expect(removeHandlerMock).toHaveBeenCalledWith('repos:pickFolders')
})
it('picks multiple folders for the add-project browse flow', async () => {
const projectA = join(sep, 'projects', 'a')
const projectB = join(sep, 'projects', 'b')
showOpenDialogMock.mockResolvedValue({
canceled: false,
filePaths: [projectA, projectB]
})
await expect(callPickFolders()).resolves.toEqual([projectA, projectB])
expect(showOpenDialogMock).toHaveBeenCalledWith(mockWindow, {
properties: ['openDirectory', 'multiSelections']
})
})
it('returns an empty folder list when multi-folder picking is canceled', async () => {
showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] })
await expect(callPickFolders()).resolves.toEqual([])
})
})

View File

@ -1101,6 +1101,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('folderWorkspaces:delete')
ipcMain.removeHandler('folderWorkspaces:getPathStatus')
ipcMain.removeHandler('repos:pickFolder')
ipcMain.removeHandler('repos:pickFolders')
ipcMain.removeHandler('repos:pickDirectory')
ipcMain.removeHandler('repos:clone')
ipcMain.removeHandler('repos:cloneAbort')
@ -2028,6 +2029,16 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
return result.filePaths[0]
})
ipcMain.handle('repos:pickFolders', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory', 'multiSelections']
})
if (result.canceled || result.filePaths.length === 0) {
return []
}
return result.filePaths
})
// Why: pickDirectory is a generic "choose a folder" picker, separate from
// pickFolder which is specifically the "add project" flow. Clone needs a
// destination directory that may not be a git repo yet.

View File

@ -786,6 +786,7 @@ export type PreloadApi = {
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
}) => Promise<Repo>
pickFolder: () => Promise<string | null>
pickFolders: () => Promise<string[]>
pickDirectory: () => Promise<string | null>
clone: (args: { url: string; destination: string }) => Promise<Repo>
cloneRemote: (args: { connectionId: string; url: string; destination: string }) => Promise<Repo>

View File

@ -477,6 +477,8 @@ const api = {
pickFolder: () => ipcRenderer.invoke('repos:pickFolder'),
pickFolders: () => ipcRenderer.invoke('repos:pickFolders'),
pickDirectory: () => ipcRenderer.invoke('repos:pickDirectory'),
clone: (args) => ipcRenderer.invoke('repos:clone', args),

View File

@ -0,0 +1,187 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ReactModule from 'react'
import type { NestedRepoScanResult, Repo } from '../../../../shared/types'
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof ReactModule>()
return {
...actual,
useCallback: <T extends (...args: never[]) => unknown>(fn: T) => fn,
useEffect: vi.fn(),
useRef: <T>(value: T) => ({ current: value })
}
})
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
info: vi.fn()
}
}))
vi.mock('@/lib/telemetry', () => ({
track: vi.fn()
}))
function makeScan(
path: string,
overrides: Partial<NestedRepoScanResult> = {}
): NestedRepoScanResult {
return {
selectedPath: path,
selectedPathKind: 'git_repo',
repos: [],
truncated: false,
timedOut: false,
stopped: false,
durationMs: 1,
maxDepth: 3,
maxRepos: 100,
timeoutMs: null,
...overrides
}
}
function makeRepo(path: string): Repo {
const id = path.split('/').pop() ?? path
return {
id,
path,
displayName: id,
badgeColor: '#999999',
addedAt: 1,
kind: 'git'
}
}
describe('useAddRepoLocalFolderFlow', () => {
const addRepoPath = vi.fn()
const closeModal = vi.fn()
const fetchWorktrees = vi.fn()
const scanNestedRepos = vi.fn()
const setActiveNestedScanId = vi.fn()
const setNestedScanInProgress = vi.fn()
const showNestedRepoReview = vi.fn()
const onGitRepoReady = vi.fn()
const setIsAdding = vi.fn()
const setAddProjectBusyLabel = vi.fn()
const pickFolders = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('window', {
api: {
repos: {
pickFolders
}
}
})
addRepoPath.mockImplementation(async (path: string) => makeRepo(path))
fetchWorktrees.mockResolvedValue(true)
scanNestedRepos.mockImplementation(async (path: string) => makeScan(path))
onGitRepoReady.mockResolvedValue(undefined)
})
it('adds every selected local folder and completes one default-checkout handoff', async () => {
pickFolders.mockResolvedValue(['/projects/alpha', '/projects/beta'])
const { useAddRepoLocalFolderFlow } = await import('./useAddRepoLocalFolderFlow')
const { handleBrowse } = useAddRepoLocalFolderFlow({
isOpen: true,
droppedLocalPath: '',
activeRuntimeEnvironmentId: null,
addRepoPath,
closeModal,
fetchWorktrees,
scanNestedRepos,
setActiveNestedScanId,
setNestedScanInProgress,
showNestedRepoReview,
onGitRepoReady,
setIsAdding,
setAddProjectBusyLabel
})
await handleBrowse()
expect(pickFolders).toHaveBeenCalledTimes(1)
expect(addRepoPath).toHaveBeenCalledTimes(2)
expect(addRepoPath).toHaveBeenNthCalledWith(1, '/projects/alpha')
expect(addRepoPath).toHaveBeenNthCalledWith(2, '/projects/beta')
expect(fetchWorktrees).toHaveBeenCalledWith('alpha', { requireAuthoritative: true })
expect(fetchWorktrees).toHaveBeenCalledWith('beta', { requireAuthoritative: true })
expect(onGitRepoReady).toHaveBeenCalledTimes(1)
expect(onGitRepoReady).toHaveBeenCalledWith('alpha', 'local_folder_picker')
})
it('skips nested-review folders in a multi-folder add and continues with git folders', async () => {
pickFolders.mockResolvedValue(['/projects/monorepo', '/projects/later'])
scanNestedRepos.mockImplementationOnce(async (_path, _connectionId, controls) => {
const scan = makeScan('/projects/monorepo', {
selectedPathKind: 'non_git_folder',
repos: [{ path: '/projects/monorepo/app', displayName: 'app', depth: 1 }]
})
controls?.onProgress?.(scan)
return scan
})
const { useAddRepoLocalFolderFlow } = await import('./useAddRepoLocalFolderFlow')
const { handleBrowse } = useAddRepoLocalFolderFlow({
isOpen: true,
droppedLocalPath: '',
activeRuntimeEnvironmentId: null,
addRepoPath,
closeModal,
fetchWorktrees,
scanNestedRepos,
setActiveNestedScanId,
setNestedScanInProgress,
showNestedRepoReview,
onGitRepoReady,
setIsAdding,
setAddProjectBusyLabel
})
await handleBrowse()
expect(showNestedRepoReview).not.toHaveBeenCalled()
expect(addRepoPath).toHaveBeenCalledTimes(1)
expect(addRepoPath).toHaveBeenCalledWith('/projects/later')
expect(scanNestedRepos).toHaveBeenCalledTimes(2)
expect(onGitRepoReady).toHaveBeenCalledWith('later', 'local_folder_picker')
})
it('still completes handoff when a later selected folder is skipped', async () => {
pickFolders.mockResolvedValue(['/projects/git', '/projects/monorepo'])
scanNestedRepos.mockResolvedValueOnce(makeScan('/projects/git')).mockResolvedValueOnce(
makeScan('/projects/monorepo', {
selectedPathKind: 'non_git_folder',
repos: [{ path: '/projects/monorepo/app', displayName: 'app', depth: 1 }]
})
)
const { useAddRepoLocalFolderFlow } = await import('./useAddRepoLocalFolderFlow')
const { handleBrowse } = useAddRepoLocalFolderFlow({
isOpen: true,
droppedLocalPath: '',
activeRuntimeEnvironmentId: null,
addRepoPath,
closeModal,
fetchWorktrees,
scanNestedRepos,
setActiveNestedScanId,
setNestedScanInProgress,
showNestedRepoReview,
onGitRepoReady,
setIsAdding,
setAddProjectBusyLabel
})
await handleBrowse()
expect(showNestedRepoReview).not.toHaveBeenCalled()
expect(addRepoPath).toHaveBeenCalledTimes(1)
expect(addRepoPath).toHaveBeenCalledWith('/projects/git')
expect(onGitRepoReady).toHaveBeenCalledWith('git', 'local_folder_picker')
})
})

View File

@ -22,6 +22,12 @@ type ShowNestedRepoReview = (args: {
scanId: string | null
}) => void
type LocalPathAddResult =
| { status: 'completed'; repo: Repo }
| { status: 'cancelled' | 'paused' | 'skipped' }
type LocalPathAddMode = 'single' | 'batch'
export function useAddRepoLocalFolderFlow({
isOpen,
droppedLocalPath,
@ -66,8 +72,18 @@ export function useAddRepoLocalFolderFlow({
droppedLocalPathHandledRef.current = null
}, [])
const handleAddLocalPath = useCallback(
async (path: string, source: AddRepoExistingWorkspaceSource): Promise<void> => {
const clearNestedScanState = useCallback((): void => {
setNestedScanInProgress(false)
setActiveNestedScanId(null)
}, [setActiveNestedScanId, setNestedScanInProgress])
const addLocalPathForGeneration = useCallback(
async (
path: string,
source: AddRepoExistingWorkspaceSource,
gen: number,
mode: LocalPathAddMode = 'single'
): Promise<LocalPathAddResult> => {
if (activeRuntimeEnvironmentId?.trim()) {
toast.error(
translate(
@ -76,10 +92,8 @@ export function useAddRepoLocalFolderFlow({
)
)
closeModal()
return
return { status: 'paused' }
}
const gen = ++localAddGenRef.current
setIsAdding(true)
setAddProjectBusyLabel('Scanning for repositories...')
try {
const attemptId = createNestedRepoTelemetryAttemptId()
@ -91,6 +105,7 @@ export function useAddRepoLocalFolderFlow({
onProgress: (progressScan) => {
if (
gen !== localAddGenRef.current ||
mode === 'batch' ||
progressScan.selectedPathKind !== 'non_git_folder' ||
progressScan.repos.length === 0
) {
@ -108,10 +123,9 @@ export function useAddRepoLocalFolderFlow({
}
})
if (gen !== localAddGenRef.current) {
return
return { status: 'cancelled' }
}
setNestedScanInProgress(false)
setActiveNestedScanId(null)
clearNestedScanState()
track(
'add_repo_nested_scan_result',
buildNestedRepoScanTelemetry({
@ -121,7 +135,12 @@ export function useAddRepoLocalFolderFlow({
scan
})
)
if (scan?.selectedPathKind === 'non_git_folder' && mode === 'batch') {
return { status: 'skipped' }
}
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
// Why: the existing nested-repo review is a single-folder decision point.
// Pause batch imports here instead of queueing competing review states.
showNestedRepoReview({
scan,
selectedPath: path,
@ -131,50 +150,122 @@ export function useAddRepoLocalFolderFlow({
inProgress: false,
scanId
})
return
return { status: 'paused' }
}
setAddProjectBusyLabel('Opening project...')
const repo = await addRepoPath(path)
if (gen !== localAddGenRef.current) {
return
return { status: 'cancelled' }
}
if (repo && isGitRepoKind(repo)) {
if (!repo) {
return { status: 'paused' }
}
if (isGitRepoKind(repo)) {
// Why: once the repo exists, a transient non-authoritative refresh
// should fall through to project reveal instead of leaving the add flow open.
await fetchWorktrees(repo.id, { requireAuthoritative: true })
if (gen !== localAddGenRef.current) {
return
return { status: 'cancelled' }
}
if (mode === 'batch') {
return { status: 'completed', repo }
}
await onGitRepoReady(repo.id, source)
} else if (repo) {
} else {
// Why: folder repos skip the Git default-checkout handoff and activate
// their synthetic root workspace in the folder add flow.
closeModal()
}
return { status: 'completed', repo }
} finally {
if (gen === localAddGenRef.current) {
setNestedScanInProgress(false)
setActiveNestedScanId(null)
setIsAdding(false)
setAddProjectBusyLabel(null)
clearNestedScanState()
}
}
},
[
activeRuntimeEnvironmentId,
addRepoPath,
clearNestedScanState,
closeModal,
fetchWorktrees,
onGitRepoReady,
scanNestedRepos,
setActiveNestedScanId,
setAddProjectBusyLabel,
setIsAdding,
setNestedScanInProgress,
showNestedRepoReview
]
)
const handleAddLocalPath = useCallback(
async (
path: string,
source: AddRepoExistingWorkspaceSource,
mode: LocalPathAddMode = 'single'
): Promise<LocalPathAddResult> => {
const gen = ++localAddGenRef.current
setIsAdding(true)
try {
return await addLocalPathForGeneration(path, source, gen, mode)
} finally {
if (gen === localAddGenRef.current) {
clearNestedScanState()
setIsAdding(false)
setAddProjectBusyLabel(null)
}
}
},
[addLocalPathForGeneration, clearNestedScanState, setAddProjectBusyLabel, setIsAdding]
)
const handleAddLocalPaths = useCallback(
async (paths: string[], source: AddRepoExistingWorkspaceSource, gen: number): Promise<void> => {
const gitRepoIds: string[] = []
const shouldDeferGitRepoReady = paths.length > 1
let skippedCount = 0
for (const path of paths) {
const result = await addLocalPathForGeneration(
path,
source,
gen,
shouldDeferGitRepoReady ? 'batch' : 'single'
)
if (result.status === 'skipped') {
skippedCount++
continue
}
if (result.status !== 'completed') {
return
}
if (isGitRepoKind(result.repo)) {
gitRepoIds.push(result.repo.id)
}
}
if (gen !== localAddGenRef.current) {
return
}
if (skippedCount > 0) {
toast.info(
translate(
'auto.components.sidebar.useAddRepoLocalFolderFlow.skippedBatchFolders',
'Some folders were skipped'
),
{
description: translate(
'auto.components.sidebar.useAddRepoLocalFolderFlow.skippedBatchFoldersDescription',
'Add skipped folders individually to review or confirm them.'
)
}
)
}
if (shouldDeferGitRepoReady && gitRepoIds.length > 0) {
await onGitRepoReady(gitRepoIds[0], source)
}
},
[addLocalPathForGeneration, onGitRepoReady]
)
useEffect(() => {
if (!isOpen || !droppedLocalPath) {
return
@ -191,17 +282,19 @@ export function useAddRepoLocalFolderFlow({
setIsAdding(true)
setAddProjectBusyLabel('Choose a folder...')
try {
const path = await window.api.repos.pickFolder()
if (!path || gen !== localAddGenRef.current) {
const paths = await window.api.repos.pickFolders()
if (paths.length === 0 || gen !== localAddGenRef.current) {
return
}
await handleAddLocalPath(path, 'local_folder_picker')
await handleAddLocalPaths(paths, 'local_folder_picker', gen)
} finally {
if (gen === localAddGenRef.current) {
clearNestedScanState()
setIsAdding(false)
setAddProjectBusyLabel(null)
}
}
}, [handleAddLocalPath, setAddProjectBusyLabel, setIsAdding])
}, [clearNestedScanState, handleAddLocalPaths, setAddProjectBusyLabel, setIsAdding])
return { handleBrowse, resetLocalFolderFlow }
}

View File

@ -3928,7 +3928,9 @@
"0dc4d1b657": "Enter a host path for the clone destination."
},
"useAddRepoLocalFolderFlow": {
"7ab10e4974": "Use a host path to add projects from a remote host."
"7ab10e4974": "Use a host path to add projects from a remote host.",
"skippedBatchFolders": "Some folders were skipped",
"skippedBatchFoldersDescription": "Add skipped folders individually to review or confirm them."
},
"useAddRepoNestedImportFlow": {
"680cac2c82": "{{value0}} failed",

View File

@ -3914,7 +3914,9 @@
"0dc4d1b657": "Introduzca una ruta de servidor para el destino del clon."
},
"useAddRepoLocalFolderFlow": {
"7ab10e4974": "Utilice una ruta de servidor para agregar proyectos desde un tiempo de ejecución remoto."
"7ab10e4974": "Utilice una ruta de servidor para agregar proyectos desde un tiempo de ejecución remoto.",
"skippedBatchFolders": "Se omitieron algunas carpetas",
"skippedBatchFoldersDescription": "Agrega las carpetas omitidas de forma individual para revisarlas o confirmarlas."
},
"useAddRepoNestedImportFlow": {
"680cac2c82": "{{value0}} falló",

View File

@ -3895,7 +3895,9 @@
"0dc4d1b657": "クローン先のサーバー パスを入力します。"
},
"useAddRepoLocalFolderFlow": {
"7ab10e4974": "サーバー パスを使用して、リモート ランタイムからプロジェクトを追加します。"
"7ab10e4974": "サーバー パスを使用して、リモート ランタイムからプロジェクトを追加します。",
"skippedBatchFolders": "一部のフォルダーはスキップされました",
"skippedBatchFoldersDescription": "スキップされたフォルダーは個別に追加して確認または確定してください。"
},
"useAddRepoNestedImportFlow": {
"680cac2c82": "{{value0}} は失敗しました",

View File

@ -3895,7 +3895,9 @@
"0dc4d1b657": "복제 대상의 서버 경로를 입력합니다."
},
"useAddRepoLocalFolderFlow": {
"7ab10e4974": "원격 런타임에서 프로젝트를 추가하려면 서버 경로를 사용하세요."
"7ab10e4974": "원격 런타임에서 프로젝트를 추가하려면 서버 경로를 사용하세요.",
"skippedBatchFolders": "일부 폴더를 건너뛰었습니다",
"skippedBatchFoldersDescription": "건너뛴 폴더를 검토하거나 확인하려면 개별적으로 추가하세요."
},
"useAddRepoNestedImportFlow": {
"680cac2c82": "{{value0}} 실패",

View File

@ -3895,7 +3895,9 @@
"0dc4d1b657": "输入克隆目标的服务器路径。"
},
"useAddRepoLocalFolderFlow": {
"7ab10e4974": "使用服务器路径从远程运行时添加项目。"
"7ab10e4974": "使用服务器路径从远程运行时添加项目。",
"skippedBatchFolders": "已跳过部分文件夹",
"skippedBatchFoldersDescription": "请单独添加已跳过的文件夹,以便逐个查看或确认。"
},
"useAddRepoNestedImportFlow": {
"680cac2c82": "{{value0}} 失败",

View File

@ -1012,6 +1012,7 @@ function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> {
update: async ({ repoId, updates }) =>
(await callRuntimeResult<{ repo: Repo }>('repo.update', { repo: repoId, updates })).repo,
pickFolder: () => Promise.resolve(null),
pickFolders: () => Promise.resolve([]),
pickDirectory: () => Promise.resolve(null),
clone: async ({ url, destination }) => {
invalidateRuntimeWorktreeCaches()