feat: add project from file explorer folders (#2024)

* feat: add project from file explorer folders

* test: cover file explorer add-project flow

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Leynier Gutiérrez González
2026-05-30 00:16:05 -07:00
committed by GitHub
co-authored by Neil
parent e330feb131
commit d314a2bd2e
11 changed files with 737 additions and 3 deletions
@@ -369,6 +369,8 @@ describe('FileExplorerRow collapse folder action', () => {
onStartNew: vi.fn(),
onStartRename: vi.fn(),
onDuplicate: vi.fn(),
onAddFolderAsProject: vi.fn(),
canAddFolderAsProject: () => false,
onRequestDelete: vi.fn(),
onCollapseFolderSubtree,
onFindInFolder: vi.fn(),
@@ -418,6 +420,8 @@ describe('FileExplorerRow collapse folder action', () => {
onStartNew: vi.fn(),
onStartRename: vi.fn(),
onDuplicate: vi.fn(),
onAddFolderAsProject: vi.fn(),
canAddFolderAsProject: () => false,
onRequestDelete: vi.fn(),
onCollapseFolderSubtree: vi.fn(),
onFindInFolder,
@@ -1,4 +1,4 @@
/* eslint-disable max-lines -- File Explorer coordinates tree state, selection, drag/drop, and toolbar actions in one component. */
/* eslint-disable max-lines -- Why: FileExplorer coordinates tree data, selection, drag/drop, and virtual rows; splitting it during this merge would obscure the interaction invariants. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useAppStore } from '@/store'
@@ -28,9 +28,13 @@ import { useFileExplorerImport } from './useFileExplorerImport'
import { useFileExplorerManualRefresh } from './useFileExplorerManualRefresh'
import { useFileExplorerTree } from './useFileExplorerTree'
import { useFileExplorerWatch } from './useFileExplorerWatch'
import {
buildAddProjectFromFolderModalData,
canShowAddAsProjectAction
} from './file-explorer-add-project-action'
import type { TreeNode } from './file-explorer-types'
import { useFileExplorerSelection } from './useFileExplorerSelection'
import { useFileExplorerGitIgnoredRows } from './useFileExplorerGitIgnoredRows'
import type { TreeNode } from './file-explorer-types'
function FileExplorerInner(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
@@ -49,6 +53,7 @@ function FileExplorerInner(): React.JSX.Element {
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
const openFiles = useAppStore((s) => s.openFiles)
const closeFile = useAppStore((s) => s.closeFile)
const openModal = useAppStore((s) => s.openModal)
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const worktreePath = activeWorktree?.path ?? null
@@ -376,6 +381,19 @@ function FileExplorerInner(): React.JSX.Element {
[activeWorktreeId, seedFileSearchIncludePattern, setRightSidebarTab, setRightSidebarOpen]
)
const handleAddFolderAsProject = useCallback(
(node: TreeNode) => {
if (!activeRepo || !canShowAddAsProjectAction(node, activeRepo)) {
return
}
openModal(
'confirm-add-project-from-folder',
buildAddProjectFromFolderModalData(node, activeRepo)
)
},
[activeRepo, openModal]
)
if (!worktreePath) {
return (
<div className="flex h-full items-center justify-center text-[11px] text-muted-foreground px-4 text-center">
@@ -487,6 +505,8 @@ function FileExplorerInner(): React.JSX.Element {
onStartNew={startNew}
onStartRename={startRename}
onDuplicate={handleDuplicate}
onAddFolderAsProject={handleAddFolderAsProject}
canAddFolderAsProject={(node) => canShowAddAsProjectAction(node, activeRepo)}
onRequestDelete={handleContextMenuDelete}
onCollapseFolderSubtree={handleCollapseFolderSubtree}
onFindInFolder={handleFindInFolder}
@@ -1,4 +1,4 @@
/* eslint-disable max-lines -- File Explorer rows own dense context-menu and drag/drop interactions. */
/* eslint-disable max-lines -- Why: the row owns dense file-tree rendering plus its context menu, drag target, and inline-input sibling contract. */
import React, { useCallback, useEffect, useRef } from 'react'
import { basename } from '@/lib/path'
import {
@@ -252,6 +252,8 @@ type FileExplorerRowProps = {
onStartNew: (type: 'file' | 'folder', dir: string, depth: number) => void
onStartRename: (node: TreeNode) => void
onDuplicate: (node: TreeNode) => void
onAddFolderAsProject: () => void
canAddAsProject: boolean
onRequestDelete: () => void
onCollapseFolderSubtree: () => void
onFindInFolder: () => void
@@ -292,6 +294,8 @@ export function FileExplorerRow({
onStartNew,
onStartRename,
onDuplicate,
onAddFolderAsProject,
canAddAsProject,
onRequestDelete,
onCollapseFolderSubtree,
onFindInFolder,
@@ -507,6 +511,12 @@ export function FileExplorerRow({
Duplicate
</ContextMenuItem>
)}
{canAddAsProject && (
<ContextMenuItem onSelect={onAddFolderAsProject}>
<FolderPlus />
Add as Project...
</ContextMenuItem>
)}
{!node.isDirectory && activeWorktreeId && (
<ContextMenuItem onSelect={handleOpenInOrcaBrowser}>
<Globe />
@@ -31,6 +31,8 @@ type FileExplorerVirtualRowsProps = {
onStartNew: (type: 'file' | 'folder', parentPath: string, depth: number) => void
onStartRename: (node: TreeNode) => void
onDuplicate: (node: TreeNode) => void
onAddFolderAsProject: (node: TreeNode) => void
canAddFolderAsProject: (node: TreeNode) => boolean
onRequestDelete: (node: TreeNode) => void
onCollapseFolderSubtree: (node: TreeNode) => void
onFindInFolder: (node: TreeNode) => void
@@ -69,6 +71,8 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
onStartNew,
onStartRename,
onDuplicate,
onAddFolderAsProject,
canAddFolderAsProject,
onRequestDelete,
onCollapseFolderSubtree,
onFindInFolder,
@@ -169,6 +173,8 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
onStartNew={onStartNew}
onStartRename={onStartRename}
onDuplicate={onDuplicate}
onAddFolderAsProject={() => onAddFolderAsProject(n)}
canAddAsProject={canAddFolderAsProject(n)}
onRequestDelete={() => onRequestDelete(n)}
onCollapseFolderSubtree={() => onCollapseFolderSubtree(n)}
onFindInFolder={() => onFindInFolder(n)}
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest'
import { FileExplorerRow } from './FileExplorerRow'
import { FileExplorerVirtualRows } from './FileExplorerVirtualRows'
import type { TreeNode } from './file-explorer-types'
type ReactElementLike = {
type: unknown
props: Record<string, unknown>
}
function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return
}
if (Array.isArray(node)) {
node.forEach((entry) => visit(entry, cb))
return
}
const element = node as ReactElementLike
cb(element)
if (element.props?.children) {
visit(element.props.children, cb)
}
}
function findFileExplorerRow(node: unknown): ReactElementLike {
let found: ReactElementLike | null = null
visit(node, (entry) => {
if (entry.type === FileExplorerRow) {
found = entry
}
})
if (!found) {
throw new Error('file explorer row not found')
}
return found
}
const directoryNode: TreeNode = {
name: 'src',
path: '/repo/src',
relativePath: 'src',
isDirectory: true,
depth: 0
}
describe('FileExplorerVirtualRows add-as-project action', () => {
it('passes visibility and the row node to the add-as-project handler', () => {
const onAddFolderAsProject = vi.fn()
const element = FileExplorerVirtualRows({
virtualizer: {
getTotalSize: () => 26,
getVirtualItems: () => [{ index: 0, key: 'src', start: 0 }],
measureElement: vi.fn()
} as never,
inlineInputIndex: -1,
flatRows: [directoryNode],
inlineInput: null,
handleInlineSubmit: vi.fn(),
dismissInlineInput: vi.fn(),
folderStatusByRelativePath: new Map(),
statusByRelativePath: new Map(),
ignoredByRelativePath: new Set(),
expanded: new Set([directoryNode.path]),
dirCache: {},
selectedPaths: new Set(),
activeFileId: null,
flashingPath: null,
deleteShortcutLabel: 'Del',
onClick: vi.fn(),
onDoubleClick: vi.fn(),
onContextMenuSelect: vi.fn(),
onCopyPaths: vi.fn(),
onStartNew: vi.fn(),
onStartRename: vi.fn(),
onDuplicate: vi.fn(),
onAddFolderAsProject,
canAddFolderAsProject: (node) => node.path === directoryNode.path,
onRequestDelete: vi.fn(),
onCollapseFolderSubtree: vi.fn(),
onFindInFolder: vi.fn(),
onMoveDrop: vi.fn(),
onDragTargetChange: vi.fn(),
onDragSourceChange: vi.fn(),
onDragExpandDir: vi.fn(),
onNativeDragTargetChange: vi.fn(),
onNativeDragExpandDir: vi.fn(),
dropTargetDir: null,
dragSourcePath: null,
nativeDropTargetDir: null
})
const row = findFileExplorerRow(element)
expect(row.props.canAddAsProject).toBe(true)
;(row.props.onAddFolderAsProject as () => void)()
expect(onAddFolderAsProject).toHaveBeenCalledWith(directoryNode)
})
})
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import type { Repo } from '../../../../shared/types'
import type { TreeNode } from './file-explorer-types'
import {
buildAddProjectFromFolderModalData,
canShowAddAsProjectAction
} from './file-explorer-add-project-action'
const folderNode: TreeNode = {
name: 'child-project',
path: '/projects/child-project',
relativePath: 'child-project',
isDirectory: true,
depth: 0
}
const fileNode: TreeNode = {
...folderNode,
name: 'README.md',
path: '/projects/README.md',
relativePath: 'README.md',
isDirectory: false
}
const folderRepo: Repo = {
id: 'folder-repo',
path: '/projects',
displayName: 'projects',
badgeColor: '#000',
addedAt: 1,
kind: 'folder'
}
const gitRepo: Repo = {
...folderRepo,
id: 'git-repo',
kind: 'git'
}
describe('file explorer add project action', () => {
it('shows only for directories in folder-mode projects', () => {
expect(canShowAddAsProjectAction(folderNode, folderRepo)).toBe(true)
expect(canShowAddAsProjectAction(fileNode, folderRepo)).toBe(false)
expect(canShowAddAsProjectAction(folderNode, gitRepo)).toBe(false)
expect(canShowAddAsProjectAction(folderNode, null)).toBe(false)
})
it('preserves the selected folder path and active SSH connection', () => {
expect(
buildAddProjectFromFolderModalData(folderNode, {
...folderRepo,
connectionId: 'ssh-target-1'
})
).toEqual({
folderPath: '/projects/child-project',
connectionId: 'ssh-target-1'
})
})
})
@@ -0,0 +1,22 @@
import { isFolderRepo } from '../../../../shared/repo-kind'
import type { Repo } from '../../../../shared/types'
import type { TreeNode } from './file-explorer-types'
export type AddProjectFromFolderModalData = {
folderPath: string
connectionId?: string
}
export function canShowAddAsProjectAction(node: TreeNode, activeRepo: Repo | null): boolean {
return node.isDirectory && Boolean(activeRepo && isFolderRepo(activeRepo))
}
export function buildAddProjectFromFolderModalData(
node: TreeNode,
activeRepo: Repo
): AddProjectFromFolderModalData {
return {
folderPath: node.path,
...(activeRepo.connectionId ? { connectionId: activeRepo.connectionId } : {})
}
}
@@ -0,0 +1,234 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type * as ReactModule from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo, Worktree } from '../../../../shared/types'
type ButtonCapture = {
label: string
onClick?: () => unknown
disabled?: boolean
}
const mocks = vi.hoisted(() => ({
buttons: [] as ButtonCapture[],
state: {
activeModal: 'confirm-add-project-from-folder',
modalData: {} as Record<string, unknown>,
closeModal: vi.fn(),
openModal: vi.fn(),
addRepoPath: vi.fn(),
updateRepo: vi.fn(),
fetchWorktrees: vi.fn(),
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn(),
worktreesByRepo: {} as Record<string, Worktree[]>,
detectedWorktreesByRepo: {},
hideDefaultBranchWorkspace: false,
setHideDefaultBranchWorkspace: vi.fn(),
clearOrcaHookTrustForRepo: vi.fn(),
repos: [] as Repo[]
},
addRemote: vi.fn(),
toastSuccess: vi.fn(),
track: vi.fn()
}))
function textContent(node: ReactModule.ReactNode): string {
if (node == null || typeof node === 'boolean') {
return ''
}
if (typeof node === 'string' || typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
return node.map(textContent).join('')
}
if (typeof node === 'object' && 'props' in node) {
return textContent((node as { props?: { children?: ReactModule.ReactNode } }).props?.children)
}
return ''
}
vi.mock('@/store', () => {
const useAppStore = Object.assign(
(selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
{
getState: () => mocks.state,
setState: (next: Partial<typeof mocks.state>) => {
Object.assign(mocks.state, next)
}
}
)
return { useAppStore }
})
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactModule.ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactModule.ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: ReactModule.ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactModule.ReactNode }) => <footer>{children}</footer>,
DialogHeader: ({ children }: { children: ReactModule.ReactNode }) => <header>{children}</header>,
DialogTitle: ({ children }: { children: ReactModule.ReactNode }) => <h1>{children}</h1>
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
onClick,
disabled
}: {
children: ReactModule.ReactNode
onClick?: () => unknown
disabled?: boolean
}) => {
mocks.buttons.push({ label: textContent(children), onClick, disabled })
return (
<button disabled={disabled} onClick={onClick}>
{children}
</button>
)
}
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn()
}))
vi.mock('@/lib/telemetry', () => ({
track: mocks.track
}))
vi.mock('sonner', () => ({
toast: {
success: mocks.toastSuccess,
error: vi.fn(),
info: vi.fn()
}
}))
vi.mock('./AddRepoSetupStep', () => ({
SetupStep: ({ repoName }: { repoName: string }) => <div>setup:{repoName}</div>,
getProjectAddedPrimaryBranchName: () => 'main'
}))
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/projects/child',
displayName: 'child',
badgeColor: '#999999',
addedAt: 1,
kind: 'git',
...overrides
}
}
async function clickAddProject(): Promise<void> {
const button = mocks.buttons.find((entry) => entry.label.includes('Add Project'))
if (!button?.onClick) {
throw new Error('Add Project button not found')
}
await button.onClick()
}
describe('AddProjectFromFolderDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.buttons = []
mocks.state.activeModal = 'confirm-add-project-from-folder'
mocks.state.modalData = { folderPath: '/projects/child' }
mocks.state.worktreesByRepo = {}
mocks.state.detectedWorktreesByRepo = {}
mocks.state.hideDefaultBranchWorkspace = false
mocks.state.repos = []
vi.stubGlobal('window', {
api: {
repos: {
addRemote: mocks.addRemote
}
}
})
})
it('adds a local Git folder and opens the reused setup step data path', async () => {
const repo = makeRepo()
mocks.state.addRepoPath.mockResolvedValue(repo)
const { default: AddProjectFromFolderDialog } = await import('./AddProjectFromFolderDialog')
renderToStaticMarkup(<AddProjectFromFolderDialog />)
await clickAddProject()
expect(mocks.state.addRepoPath).toHaveBeenCalledWith('/projects/child')
expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith(repo.id)
expect(mocks.state.openModal).not.toHaveBeenCalledWith(
'confirm-non-git-folder',
expect.anything()
)
})
it('leaves local non-Git folders on the existing Open as Folder confirmation path', async () => {
mocks.state.addRepoPath.mockImplementation(async (folderPath: string) => {
mocks.state.openModal('confirm-non-git-folder', { folderPath })
return null
})
const { default: AddProjectFromFolderDialog } = await import('./AddProjectFromFolderDialog')
renderToStaticMarkup(<AddProjectFromFolderDialog />)
await clickAddProject()
expect(mocks.state.addRepoPath).toHaveBeenCalledWith('/projects/child')
expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-non-git-folder', {
folderPath: '/projects/child'
})
expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
})
it('adds an SSH Git folder through the remote repo import path', async () => {
const repo = makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })
mocks.state.modalData = {
folderPath: '/srv/projects/child',
connectionId: 'ssh-target-1'
}
mocks.addRemote.mockResolvedValue({ repo })
const { default: AddProjectFromFolderDialog } = await import('./AddProjectFromFolderDialog')
renderToStaticMarkup(<AddProjectFromFolderDialog />)
await clickAddProject()
expect(mocks.addRemote).toHaveBeenCalledWith({
connectionId: 'ssh-target-1',
remotePath: '/srv/projects/child'
})
expect(mocks.state.repos).toEqual([repo])
expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith(repo.id)
expect(mocks.toastSuccess).toHaveBeenCalledWith('Remote project added', {
description: repo.displayName
})
})
it('sends SSH non-Git folders to the Open as Folder confirmation with the connection id', async () => {
mocks.state.modalData = {
folderPath: '/srv/projects/docs',
connectionId: 'ssh-target-1'
}
mocks.addRemote.mockResolvedValue({
error: 'Not a valid git repository: /srv/projects/docs'
})
const { default: AddProjectFromFolderDialog } = await import('./AddProjectFromFolderDialog')
renderToStaticMarkup(<AddProjectFromFolderDialog />)
await clickAddProject()
expect(mocks.addRemote).toHaveBeenCalledWith({
connectionId: 'ssh-target-1',
remotePath: '/srv/projects/docs'
})
expect(mocks.state.closeModal).toHaveBeenCalled()
expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-non-git-folder', {
folderPath: '/srv/projects/docs',
connectionId: 'ssh-target-1'
})
expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,277 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { FolderPlus, Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { track } from '@/lib/telemetry'
import type { Repo } from '../../../../shared/types'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { getProjectAddedPrimaryBranchName, SetupStep } from './AddRepoSetupStep'
import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization'
import {
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from '../../../../shared/worktree-ownership'
const NON_GIT_REPO_ERROR = 'Not a valid git repository'
const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const modalData = useAppStore((s) => s.modalData)
const closeModal = useAppStore((s) => s.closeModal)
const openModal = useAppStore((s) => s.openModal)
const addRepoPath = useAppStore((s) => s.addRepoPath)
const updateRepo = useAppStore((s) => s.updateRepo)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace)
const [addedRepo, setAddedRepo] = useState<Repo | null>(null)
const [isAdding, setIsAdding] = useState(false)
const [error, setError] = useState<string | null>(null)
const isOpen = activeModal === 'confirm-add-project-from-folder'
const folderPath = typeof modalData.folderPath === 'string' ? modalData.folderPath : ''
const connectionId = typeof modalData.connectionId === 'string' ? modalData.connectionId : ''
const repoId = addedRepo?.id ?? ''
const worktrees = useMemo(() => {
return worktreesByRepo[repoId] ?? []
}, [repoId, worktreesByRepo])
const detectedResult = repoId ? detectedWorktreesByRepo[repoId] : undefined
const hiddenWorktreeCount =
detectedResult?.authoritative === true
? detectedResult.worktrees.filter(
(worktree) => !worktree.selectedCheckout && worktree.ownership !== 'orca-managed'
).length
: 0
const otherWorktreesVisible = addedRepo
? effectiveExternalWorktreeVisibility(
addedRepo,
isLegacyRepoForExternalWorktreeVisibility(addedRepo)
) === 'show'
: false
const sortedWorktrees = useMemo(() => {
return [...worktrees].sort((a, b) => {
if (a.lastActivityAt !== b.lastActivityAt) {
return b.lastActivityAt - a.lastActivityAt
}
return a.displayName.localeCompare(b.displayName)
})
}, [worktrees])
const primaryWorktree = useMemo(
() => sortedWorktrees.find((worktree) => worktree.isMainWorktree) ?? null,
[sortedWorktrees]
)
const primaryBranchName = getProjectAddedPrimaryBranchName(primaryWorktree)
useEffect(() => {
if (!isOpen) {
setAddedRepo(null)
setIsAdding(false)
setError(null)
}
}, [isOpen])
const openNonGitConfirmation = useCallback(() => {
closeModal()
openModal('confirm-non-git-folder', {
folderPath,
...(connectionId ? { connectionId } : {})
})
}, [closeModal, connectionId, folderPath, openModal])
const handleConfirm = useCallback(async () => {
if (!folderPath || isAdding) {
return
}
setIsAdding(true)
setError(null)
try {
let repo: Repo | null
if (connectionId) {
const result = await window.api.repos.addRemote({
connectionId,
remotePath: folderPath
})
if ('error' in result) {
throw new Error(result.error)
}
repo = result.repo
const state = useAppStore.getState()
const existingIdx = state.repos.findIndex((r) => r.id === repo?.id)
if (existingIdx !== -1) {
state.clearOrcaHookTrustForRepo(repo.id)
const updated = [...state.repos]
updated[existingIdx] = repo
useAppStore.setState({ repos: updated })
} else {
useAppStore.setState({ repos: [...state.repos, repo] })
}
toast.success('Remote project added', { description: repo.displayName })
} else {
repo = await addRepoPath(folderPath)
}
if (!repo) {
return
}
if (!isGitRepoKind(repo)) {
openNonGitConfirmation()
return
}
setAddedRepo(repo)
await fetchWorktrees(repo.id)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.includes(NON_GIT_REPO_ERROR)) {
openNonGitConfirmation()
return
}
setError(message)
} finally {
setIsAdding(false)
}
}, [addRepoPath, connectionId, fetchWorktrees, folderPath, isAdding, openNonGitConfirmation])
const handleStartPrimaryWorktree = useCallback(() => {
if (!primaryWorktree) {
return
}
track('add_repo_setup_step_action', { action: 'open_primary' })
closeModal()
if (useAppStore.getState().hideDefaultBranchWorkspace) {
setHideDefaultBranchWorkspace(false)
}
activateAndRevealWorktree(primaryWorktree.id)
}, [closeModal, primaryWorktree, setHideDefaultBranchWorkspace])
const handleUseExistingWorktrees = useCallback(async () => {
if (!repoId) {
return
}
track('add_repo_setup_step_action', { action: 'open_existing' })
if (!otherWorktreesVisible) {
const updated = await updateRepo(repoId, { externalWorktreeVisibility: 'show' })
if (updated && addedRepo) {
setAddedRepo({ ...addedRepo, externalWorktreeVisibility: 'show' })
}
}
closeModal()
await fetchWorktrees(repoId)
finalizeImportedRepoAfterSkip(useAppStore.getState(), repoId)
}, [addedRepo, closeModal, fetchWorktrees, otherWorktreesVisible, repoId, updateRepo])
const handleCreateWorktree = useCallback(
(name?: string) => {
if (!repoId) {
return
}
track('add_repo_setup_step_action', { action: 'create_worktree' })
closeModal()
setTimeout(() => {
openModal('new-workspace-composer', {
initialRepoId: repoId,
...(name ? { prefilledName: name } : {}),
telemetrySource: 'sidebar'
})
}, 150)
},
[closeModal, openModal, repoId]
)
const handleConfigureRepo = useCallback(() => {
if (!repoId) {
return
}
track('add_repo_setup_step_action', { action: 'configure' })
closeModal()
openSettingsTarget({ pane: 'repo', repoId })
openSettingsPage()
}, [closeModal, openSettingsPage, openSettingsTarget, repoId])
const handleSkip = useCallback(async () => {
if (!repoId) {
closeModal()
return
}
track('add_repo_setup_step_action', { action: 'skip' })
closeModal()
await fetchWorktrees(repoId)
finalizeImportedRepoAfterSkip(useAppStore.getState(), repoId)
}, [closeModal, fetchWorktrees, repoId])
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
if (addedRepo) {
void handleSkip()
return
}
closeModal()
}
},
[addedRepo, closeModal, handleSkip]
)
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg">
{addedRepo ? (
<SetupStep
repoName={addedRepo.displayName}
hiddenWorktreeCount={hiddenWorktreeCount}
primaryBranchName={primaryBranchName}
onStartPrimaryWorktree={handleStartPrimaryWorktree}
onUseExistingWorktrees={() => void handleUseExistingWorktrees()}
onCreateWorktree={handleCreateWorktree}
onConfigureRepo={handleConfigureRepo}
/>
) : (
<>
<DialogHeader>
<DialogTitle>Add Project</DialogTitle>
<DialogDescription>Add this folder as a separate Orca project.</DialogDescription>
</DialogHeader>
{folderPath && (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-all font-mono text-muted-foreground">{folderPath}</div>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isAdding}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!folderPath || isAdding}>
{isAdding ? (
<Loader2 className="size-4 animate-spin" />
) : (
<FolderPlus className="size-4" />
)}
Add Project
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
})
export default AddProjectFromFolderDialog
@@ -11,6 +11,7 @@ import WorktreeMetaDialog from './WorktreeMetaDialog'
import NonGitFolderDialog from './NonGitFolderDialog'
import RemoveFolderDialog from './RemoveFolderDialog'
import AddRepoDialog from './AddRepoDialog'
import AddProjectFromFolderDialog from './AddProjectFromFolderDialog'
import ProjectAddedDialog from './ProjectAddedDialog'
import WorktreeVisibilityDialog from './WorktreeVisibilityDialog'
import OrcaYamlTrustDialog from './OrcaYamlTrustDialog'
@@ -98,6 +99,7 @@ function Sidebar({
<NonGitFolderDialog />
<RemoveFolderDialog />
<AddRepoDialog />
<AddProjectFromFolderDialog />
<ProjectAddedDialog />
<WorktreeVisibilityDialog />
<OrcaYamlTrustDialog />
+1
View File
@@ -481,6 +481,7 @@ export type UISlice = {
| 'create-worktree'
| 'edit-meta'
| 'delete-worktree'
| 'confirm-add-project-from-folder'
| 'confirm-non-git-folder'
| 'confirm-remove-folder'
| 'add-repo'