diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx
index df1557e5bbb..a935331905f 100644
--- a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx
+++ b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx
@@ -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,
diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx
index 6809f3cc642..fefd0f4cd6b 100644
--- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx
+++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx
@@ -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 (
@@ -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}
diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
index a9bcd4d32dc..d26f85a4b25 100644
--- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
+++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
@@ -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
)}
+ {canAddAsProject && (
+
+
+ Add as Project...
+
+ )}
{!node.isDirectory && activeWorktreeId && (
diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx
index a7a4878f5b8..0d4cb030725 100644
--- a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx
+++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx
@@ -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)}
diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx
new file mode 100644
index 00000000000..fc09c441652
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx
@@ -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
+}
+
+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)
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts
new file mode 100644
index 00000000000..95fcdeda8b6
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts
@@ -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'
+ })
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.ts b/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.ts
new file mode 100644
index 00000000000..57d317abb6c
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.ts
@@ -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 } : {})
+ }
+}
diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx
new file mode 100644
index 00000000000..0611283ce22
--- /dev/null
+++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx
@@ -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,
+ closeModal: vi.fn(),
+ openModal: vi.fn(),
+ addRepoPath: vi.fn(),
+ updateRepo: vi.fn(),
+ fetchWorktrees: vi.fn(),
+ openSettingsPage: vi.fn(),
+ openSettingsTarget: vi.fn(),
+ worktreesByRepo: {} as Record,
+ 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) => {
+ Object.assign(mocks.state, next)
+ }
+ }
+ )
+ return { useAppStore }
+})
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({ open, children }: { open: boolean; children: ReactModule.ReactNode }) =>
+ open ? {children}
: null,
+ DialogContent: ({ children }: { children: ReactModule.ReactNode }) => {children}
,
+ DialogDescription: ({ children }: { children: ReactModule.ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: ReactModule.ReactNode }) => ,
+ DialogHeader: ({ children }: { children: ReactModule.ReactNode }) => ,
+ DialogTitle: ({ children }: { children: ReactModule.ReactNode }) => {children}
+}))
+
+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 (
+
+ )
+ }
+}))
+
+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 }) => setup:{repoName}
,
+ getProjectAddedPrimaryBranchName: () => 'main'
+}))
+
+function makeRepo(overrides: Partial = {}): Repo {
+ return {
+ id: 'repo-1',
+ path: '/projects/child',
+ displayName: 'child',
+ badgeColor: '#999999',
+ addedAt: 1,
+ kind: 'git',
+ ...overrides
+ }
+}
+
+async function clickAddProject(): Promise {
+ 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ })
+})
diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx
new file mode 100644
index 00000000000..37894afa457
--- /dev/null
+++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx
@@ -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(null)
+ const [isAdding, setIsAdding] = useState(false)
+ const [error, setError] = useState(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 (
+
+ )
+})
+
+export default AddProjectFromFolderDialog
diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx
index 0e8da6e6837..f712e820b7f 100644
--- a/src/renderer/src/components/sidebar/index.tsx
+++ b/src/renderer/src/components/sidebar/index.tsx
@@ -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({
+
diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts
index 7b4ac2d0c8b..93a2f804ae2 100644
--- a/src/renderer/src/store/slices/ui.ts
+++ b/src/renderer/src/store/slices/ui.ts
@@ -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'