Fix empty nested import folder fallback (#6719)

* fix: allow empty nested import as folder

* Track open-as-folder recovery action and localize its strings

Completes the empty-nested-import fallback: adds the open_as_folder action to the existing add_repo_nested_import_action funnel (integer counts only, no paths) so adoption of the recovery path is measurable, and lands the five-locale catalog entries the new UI strings require.

Co-authored-by: Orca <help@stably.ai>

* Pin runtime-kind mock return to the literal union in nested import flow test

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
mehmet turac 2026-07-03 23:38:20 +03:00 committed by GitHub
parent 700b492b1b
commit 2c0be62df6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 353 additions and 26 deletions

View File

@ -193,22 +193,26 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
onGitRepoReady: completeGitRepoAdd,
setAddProjectBusyLabel
})
const { handleImportNestedRepos, resetNestedImportFlow, trackNestedBackAction } =
useAddRepoNestedImportFlow({
nestedAttemptId,
nestedScan,
nestedSelectedPaths,
nestedRuntimeKind,
nestedConnectionId,
nestedGroupName,
nestedImportScanId,
activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId,
fetchWorktrees,
importNestedRepos,
getNestedRepoRuntimeKind,
onGitRepoReady: completeGitRepoAdd,
setIsAdding
})
const {
handleImportNestedRepos,
handleOpenNestedRootFolder,
resetNestedImportFlow,
trackNestedBackAction
} = useAddRepoNestedImportFlow({
nestedAttemptId,
nestedScan,
nestedSelectedPaths,
nestedRuntimeKind,
nestedConnectionId,
nestedGroupName,
nestedImportScanId,
activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId,
fetchWorktrees,
importNestedRepos,
getNestedRepoRuntimeKind,
onGitRepoReady: completeGitRepoAdd,
setIsAdding
})
const resetState = useCallback(() => {
// Why: kill the git clone process if one is running, so backing out
@ -381,6 +385,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
onNestedGroupNameChange={setNestedGroupName}
onNestedSelectedPathsChange={setNestedSelectedPaths}
onImportNestedRepos={(mode) => void handleImportNestedRepos(mode)}
onOpenNestedRootFolder={() => void handleOpenNestedRootFolder()}
onCreateNameChange={(value) => {
setCreateName(value)
setCreateError(null)

View File

@ -79,6 +79,7 @@ function renderStepContent(overrides: Partial<StepContentProps>): string {
onNestedGroupNameChange: vi.fn(),
onNestedSelectedPathsChange: vi.fn(),
onImportNestedRepos: vi.fn(),
onOpenNestedRootFolder: vi.fn(),
onCreateNameChange: vi.fn(),
onCreateParentChange: vi.fn(),
onPickCreateParent: vi.fn(),
@ -120,6 +121,15 @@ describe('AddRepoDialogStepContent nested imports', () => {
expect(html).not.toContain('>Import</button>')
})
it('offers opening the parent folder when nested import selection is empty', () => {
const html = renderStepContent({ nestedSelectedPaths: new Set() })
expect(html).toContain('No repositories are selected')
expect(html).toContain('Open as Folder')
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>No, import separately<\/button>/)
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as group<\/button>/)
})
it('offers host browsing for remote create project locations', () => {
const html = renderStepContent({
step: 'create',

View File

@ -72,6 +72,7 @@ type AddRepoDialogStepContentProps = {
onNestedGroupNameChange: (name: string) => void
onNestedSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
onImportNestedRepos: (mode: 'group' | 'separate') => void
onOpenNestedRootFolder: () => void
onCreateNameChange: (name: string) => void
onCreateParentChange: (parent: string) => void
onPickCreateParent: () => void
@ -140,6 +141,7 @@ export function AddRepoDialogStepContent({
onNestedGroupNameChange,
onNestedSelectedPathsChange,
onImportNestedRepos,
onOpenNestedRootFolder,
onCreateNameChange,
onCreateParentChange,
onPickCreateParent,
@ -237,6 +239,7 @@ export function AddRepoDialogStepContent({
onGroupNameChange={onNestedGroupNameChange}
onSelectedPathsChange={onNestedSelectedPathsChange}
onImport={onImportNestedRepos}
onOpenAsFolder={onOpenNestedRootFolder}
onStopScan={onStopNestedScan}
/>
)

View File

@ -41,6 +41,7 @@ function renderStepMarkup(
onGroupNameChange={vi.fn()}
onSelectedPathsChange={vi.fn()}
onImport={vi.fn()}
onOpenAsFolder={vi.fn()}
onStopScan={vi.fn()}
{...overrides}
/>
@ -122,6 +123,7 @@ describe('AddRepoNestedImportStep', () => {
onGroupNameChange={vi.fn()}
onSelectedPathsChange={vi.fn()}
onImport={onImport}
onOpenAsFolder={vi.fn()}
onStopScan={vi.fn()}
/>
</Dialog>
@ -162,6 +164,7 @@ describe('AddRepoNestedImportStep', () => {
onImport(mode)
setIsAdding(true)
}}
onOpenAsFolder={vi.fn()}
onStopScan={vi.fn()}
/>
</Dialog>
@ -181,4 +184,44 @@ describe('AddRepoNestedImportStep', () => {
expect(findButton(host, 'Yes, import as group').querySelector('.animate-spin')).not.toBeNull()
expect(findButton(host, 'No, import separately').querySelector('.animate-spin')).toBeNull()
})
it('offers opening the parent folder when no repositories are selected', () => {
const onOpenAsFolder = vi.fn()
const host = document.createElement('div')
container = host
document.body.appendChild(host)
root = createRoot(host)
act(() => {
root?.render(
<TooltipProvider>
<Dialog open>
<AddRepoNestedImportStep
scan={scan}
groupName=""
selectedPaths={new Set()}
isAdding={false}
scanInProgress={false}
onGroupNameChange={vi.fn()}
onSelectedPathsChange={vi.fn()}
onImport={vi.fn()}
onOpenAsFolder={onOpenAsFolder}
onStopScan={vi.fn()}
/>
</Dialog>
</TooltipProvider>
)
})
expect(host.textContent).toContain('No repositories are selected')
expect(findButton(host, 'No, import separately').disabled).toBe(true)
expect(findButton(host, 'Yes, import as group').disabled).toBe(true)
expect(findButton(host, 'Open as Folder').disabled).toBe(false)
act(() => {
findButton(host, 'Open as Folder').click()
})
expect(onOpenAsFolder).toHaveBeenCalledTimes(1)
})
})

View File

@ -20,6 +20,7 @@ type AddRepoNestedImportStepProps = {
onGroupNameChange: (value: string) => void
onSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
onImport: (mode: 'group' | 'separate') => void
onOpenAsFolder: () => void
onStopScan: () => void
}
@ -32,11 +33,16 @@ export function AddRepoNestedImportStep({
onGroupNameChange,
onSelectedPathsChange,
onImport,
onOpenAsFolder,
onStopScan
}: AddRepoNestedImportStepProps): React.JSX.Element {
const folderName = getRuntimePathBasename(scan.selectedPath) || scan.selectedPath
const groupNameInputId = useId()
const [pendingImportMode, setPendingImportMode] = useState<'group' | 'separate' | null>(null)
const [pendingImportMode, setPendingImportMode] = useState<
'folder' | 'group' | 'separate' | null
>(null)
const noRepositoriesSelected = selectedPaths.size === 0
const showOpenAsFolderSpinner = isAdding && pendingImportMode === 'folder'
const showSeparateSpinner = isAdding && pendingImportMode === 'separate'
const showGroupSpinner = isAdding && pendingImportMode === 'group'
@ -50,6 +56,10 @@ export function AddRepoNestedImportStep({
setPendingImportMode(mode)
onImport(mode)
}
const handleOpenAsFolder = (): void => {
setPendingImportMode('folder')
onOpenAsFolder()
}
const repoCountLabel =
scan.repos.length === 1
? translate('auto.components.sidebar.AddRepoNestedImportStep.8401a7a0d0', '1 repository')
@ -137,10 +147,31 @@ export function AddRepoNestedImportStep({
placeholder={folderName}
/>
</div>
{noRepositoriesSelected ? (
<p className="shrink-0 text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.6149d5203f',
'No repositories are selected. Open the parent folder instead to use editor, terminal, and search without Git features.'
)}
</p>
) : null}
<div className="flex shrink-0 flex-wrap justify-end gap-2">
{noRepositoriesSelected ? (
<Button
onClick={handleOpenAsFolder}
disabled={isAdding || scanInProgress}
variant="secondary"
>
{showOpenAsFolderSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.e52454b7f6',
'Open as Folder'
)}
</Button>
) : null}
<Button
onClick={() => handleImport('separate')}
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
disabled={isAdding || scanInProgress || noRepositoriesSelected}
variant="outline"
>
{showSeparateSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
@ -151,7 +182,7 @@ export function AddRepoNestedImportStep({
</Button>
<Button
onClick={() => handleImport('group')}
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
disabled={isAdding || scanInProgress || noRepositoriesSelected}
>
{showGroupSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
{translate(

View File

@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ReactModule from 'react'
import type { NestedRepoScanResult, ProjectGroupImportResult, 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,
useRef: <T>(value: T) => ({ current: value })
}
})
const folderRepo: Repo = {
id: 'platform',
path: '/workspace/platform',
displayName: 'platform',
badgeColor: '#999999',
addedAt: 1,
kind: 'folder'
}
const mocks = vi.hoisted(() => ({
state: {
repos: [] as Repo[],
addNonGitFolder: vi.fn(),
closeModal: vi.fn(),
openModal: vi.fn()
}
}))
vi.mock('@/store', () => {
const useAppStore = Object.assign(
(selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
{
getState: () => mocks.state
}
)
return { useAppStore }
})
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
warning: vi.fn()
}
}))
vi.mock('@/lib/telemetry', () => ({
track: vi.fn()
}))
import { track } from '@/lib/telemetry'
import { useAddRepoNestedImportFlow } from './useAddRepoNestedImportFlow'
const scan: NestedRepoScanResult = {
selectedPath: '/workspace/platform',
selectedPathKind: 'non_git_folder',
repos: [{ path: '/workspace/platform/app', displayName: 'app', depth: 1 }],
truncated: false,
timedOut: false,
stopped: false,
durationMs: 3,
maxDepth: 3,
maxRepos: 100,
timeoutMs: null
}
function useTestAddRepoNestedImportFlow(
overrides: Partial<Parameters<typeof useAddRepoNestedImportFlow>[0]> = {}
): ReturnType<typeof useAddRepoNestedImportFlow> {
return useAddRepoNestedImportFlow({
nestedAttemptId: 'attempt-1',
nestedScan: scan,
nestedSelectedPaths: new Set(),
nestedRuntimeKind: 'local',
nestedConnectionId: null,
nestedGroupName: 'platform',
nestedImportScanId: 'scan-1',
activeRuntimeEnvironmentId: null,
fetchWorktrees: vi.fn(),
importNestedRepos: vi.fn<() => Promise<ProjectGroupImportResult | null>>(),
getNestedRepoRuntimeKind: vi.fn(() => 'local' as const),
onGitRepoReady: vi.fn(),
setIsAdding: vi.fn(),
...overrides
})
}
describe('useAddRepoNestedImportFlow open folder fallback', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.state.repos = []
mocks.state.addNonGitFolder.mockResolvedValue(folderRepo)
})
it('opens the scanned local root through the existing non-git folder flow', async () => {
const setIsAdding = vi.fn()
const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({ setIsAdding })
await handleOpenNestedRootFolder()
expect(mocks.state.addNonGitFolder).toHaveBeenCalledWith('/workspace/platform', {
runtimeEnvironmentId: null
})
expect(mocks.state.closeModal).toHaveBeenCalledTimes(1)
expect(setIsAdding).toHaveBeenNthCalledWith(1, true)
expect(setIsAdding).toHaveBeenNthCalledWith(2, false)
})
it('keeps runtime folder opens on the runtime that produced the scan', async () => {
const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({
activeRuntimeEnvironmentId: 'env-1'
})
await handleOpenNestedRootFolder()
expect(mocks.state.addNonGitFolder).toHaveBeenCalledWith('/workspace/platform', {
runtimeEnvironmentId: 'env-1'
})
})
it('tracks the open-as-folder recovery action with zero selection', async () => {
const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow()
await handleOpenNestedRootFolder()
expect(track).toHaveBeenCalledWith(
'add_repo_nested_import_action',
expect.objectContaining({
action: 'open_as_folder',
surface: 'sidebar',
runtime_kind: 'local',
found_count: 1,
selected_count: 0
})
)
})
it('uses the existing SSH non-git folder confirmation for SSH scans', async () => {
const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({
nestedConnectionId: 'ssh-builder',
nestedRuntimeKind: 'ssh'
})
await handleOpenNestedRootFolder()
expect(mocks.state.addNonGitFolder).not.toHaveBeenCalled()
expect(mocks.state.closeModal).toHaveBeenCalledTimes(1)
expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-non-git-folder', {
folderPath: '/workspace/platform',
connectionId: 'ssh-builder'
})
})
})

View File

@ -50,6 +50,7 @@ export function useAddRepoNestedImportFlow({
setIsAdding: (isAdding: boolean) => void
}): {
handleImportNestedRepos: (mode: 'group' | 'separate') => Promise<void>
handleOpenNestedRootFolder: () => Promise<void>
resetNestedImportFlow: () => void
trackNestedBackAction: () => void
} {
@ -236,5 +237,69 @@ export function useAddRepoNestedImportFlow({
]
)
return { handleImportNestedRepos, resetNestedImportFlow, trackNestedBackAction }
const handleOpenNestedRootFolder = useCallback(async (): Promise<void> => {
if (!nestedScan) {
return
}
const gen = ++nestedImportGenRef.current
const path = nestedScan.selectedPath
if (nestedAttemptId) {
track(
'add_repo_nested_import_action',
buildNestedRepoImportActionTelemetry({
attemptId: nestedAttemptId,
surface: 'sidebar',
runtimeKind: nestedRuntimeKind ?? getNestedRepoRuntimeKind(nestedConnectionId),
action: 'open_as_folder',
foundCount: nestedScan.repos.length,
selectedCount: nestedSelectedPaths.size
})
)
}
setIsAdding(true)
try {
const state = useAppStore.getState()
if (nestedConnectionId) {
state.closeModal()
state.openModal('confirm-non-git-folder', {
folderPath: path,
connectionId: nestedConnectionId
})
return
}
const repo = await state.addNonGitFolder(path, {
runtimeEnvironmentId: activeRuntimeEnvironmentId?.trim() || null
})
if (gen !== nestedImportGenRef.current) {
return
}
if (repo) {
useAppStore.getState().closeModal()
}
} catch (err) {
if (gen === nestedImportGenRef.current) {
toast.error(err instanceof Error ? err.message : String(err))
}
} finally {
if (gen === nestedImportGenRef.current) {
setIsAdding(false)
}
}
}, [
activeRuntimeEnvironmentId,
getNestedRepoRuntimeKind,
nestedAttemptId,
nestedConnectionId,
nestedRuntimeKind,
nestedScan,
nestedSelectedPaths.size,
setIsAdding
])
return {
handleImportNestedRepos,
handleOpenNestedRootFolder,
resetNestedImportFlow,
trackNestedBackAction
}
}

View File

@ -3523,7 +3523,9 @@
"8401a7a0d0": "1 repository",
"d4f1df62ef": "{{value0}} repositories",
"b4263a2ac4": "Found {{value0}} in {{value1}}.",
"24eda6c8b2": "Scanning... {{value0}}"
"24eda6c8b2": "Scanning... {{value0}}",
"6149d5203f": "No repositories are selected. Open the parent folder instead to use editor, terminal, and search without Git features.",
"e52454b7f6": "Open as Folder"
},
"AddRepoRemoteStep": {
"5b205b5281": "Stop scan",

View File

@ -3508,7 +3508,9 @@
"8401a7a0d0": "1 repositorio",
"d4f1df62ef": "{{value0}} repositorios",
"b4263a2ac4": "Se encontraron {{value0}} en {{value1}}.",
"24eda6c8b2": "Explorando... {{value0}}"
"24eda6c8b2": "Explorando... {{value0}}",
"6149d5203f": "No hay repositorios seleccionados. Abre la carpeta principal para usar el editor, la terminal y la búsqueda sin funciones de Git.",
"e52454b7f6": "Abrir como carpeta"
},
"AddRepoRemoteStep": {
"5b205b5281": "Detener escaneo",

View File

@ -3489,7 +3489,9 @@
"8401a7a0d0": "1 個の repos",
"d4f1df62ef": "{{value0}} 個の repos",
"b4263a2ac4": "{{value1}} で {{value0}} が見つかりました。",
"24eda6c8b2": "スキャン中... {{value0}}"
"24eda6c8b2": "スキャン中... {{value0}}",
"6149d5203f": "選択されている repos がありません。代わりに親フォルダーを開くと、Git 機能なしでエディター、terminal、検索を利用できます。",
"e52454b7f6": "フォルダーとして開く"
},
"AddRepoRemoteStep": {
"5b205b5281": "スキャンの停止",

View File

@ -3489,7 +3489,9 @@
"8401a7a0d0": "repos 1개",
"d4f1df62ef": "repos {{value0}}개",
"b4263a2ac4": "{{value1}}에서 {{value0}}을(를) 찾았습니다.",
"24eda6c8b2": "스캔 중... {{value0}}"
"24eda6c8b2": "스캔 중... {{value0}}",
"6149d5203f": "선택된 repos가 없습니다. 대신 상위 폴더를 열면 Git 기능 없이 편집기, terminal, 검색을 사용할 수 있습니다.",
"e52454b7f6": "폴더로 열기"
},
"AddRepoRemoteStep": {
"5b205b5281": "스캔 중지",

View File

@ -3489,7 +3489,9 @@
"8401a7a0d0": "1 个仓库",
"d4f1df62ef": "{{value0}} 个仓库",
"b4263a2ac4": "在 {{value1}} 中找到 {{value0}}。",
"24eda6c8b2": "正在扫描... {{value0}}"
"24eda6c8b2": "正在扫描... {{value0}}",
"6149d5203f": "未选择任何仓库。改为打开父文件夹,即可在没有 Git 功能的情况下使用编辑器、终端和搜索。",
"e52454b7f6": "作为文件夹打开"
},
"AddRepoRemoteStep": {
"5b205b5281": "停止扫描",

View File

@ -20,7 +20,12 @@ export const NESTED_REPO_SCAN_RESULTS = [
] as const
export type NestedRepoScanTelemetryResult = (typeof NESTED_REPO_SCAN_RESULTS)[number]
export const NESTED_REPO_IMPORT_ACTIONS = ['import_group', 'import_separate', 'back'] as const
export const NESTED_REPO_IMPORT_ACTIONS = [
'import_group',
'import_separate',
'open_as_folder',
'back'
] as const
export type NestedRepoImportTelemetryAction = (typeof NESTED_REPO_IMPORT_ACTIONS)[number]
export const NESTED_REPO_IMPORT_OUTCOMES = ['success', 'partial_failure', 'failed'] as const