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:
parent
52f2a32e62
commit
2a42d46719
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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ó",
|
||||
|
|
|
|||
|
|
@ -3895,7 +3895,9 @@
|
|||
"0dc4d1b657": "クローン先のサーバー パスを入力します。"
|
||||
},
|
||||
"useAddRepoLocalFolderFlow": {
|
||||
"7ab10e4974": "サーバー パスを使用して、リモート ランタイムからプロジェクトを追加します。"
|
||||
"7ab10e4974": "サーバー パスを使用して、リモート ランタイムからプロジェクトを追加します。",
|
||||
"skippedBatchFolders": "一部のフォルダーはスキップされました",
|
||||
"skippedBatchFoldersDescription": "スキップされたフォルダーは個別に追加して確認または確定してください。"
|
||||
},
|
||||
"useAddRepoNestedImportFlow": {
|
||||
"680cac2c82": "{{value0}} は失敗しました",
|
||||
|
|
|
|||
|
|
@ -3895,7 +3895,9 @@
|
|||
"0dc4d1b657": "복제 대상의 서버 경로를 입력합니다."
|
||||
},
|
||||
"useAddRepoLocalFolderFlow": {
|
||||
"7ab10e4974": "원격 런타임에서 프로젝트를 추가하려면 서버 경로를 사용하세요."
|
||||
"7ab10e4974": "원격 런타임에서 프로젝트를 추가하려면 서버 경로를 사용하세요.",
|
||||
"skippedBatchFolders": "일부 폴더를 건너뛰었습니다",
|
||||
"skippedBatchFoldersDescription": "건너뛴 폴더를 검토하거나 확인하려면 개별적으로 추가하세요."
|
||||
},
|
||||
"useAddRepoNestedImportFlow": {
|
||||
"680cac2c82": "{{value0}} 실패",
|
||||
|
|
|
|||
|
|
@ -3895,7 +3895,9 @@
|
|||
"0dc4d1b657": "输入克隆目标的服务器路径。"
|
||||
},
|
||||
"useAddRepoLocalFolderFlow": {
|
||||
"7ab10e4974": "使用服务器路径从远程运行时添加项目。"
|
||||
"7ab10e4974": "使用服务器路径从远程运行时添加项目。",
|
||||
"skippedBatchFolders": "已跳过部分文件夹",
|
||||
"skippedBatchFoldersDescription": "请单独添加已跳过的文件夹,以便逐个查看或确认。"
|
||||
},
|
||||
"useAddRepoNestedImportFlow": {
|
||||
"680cac2c82": "{{value0}} 失败",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue