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 }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactModule.ReactNode }) =>
{children}
, + 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 ( + + + {addedRepo ? ( + void handleUseExistingWorktrees()} + onCreateWorktree={handleCreateWorktree} + onConfigureRepo={handleConfigureRepo} + /> + ) : ( + <> + + Add Project + Add this folder as a separate Orca project. + + + {folderPath && ( +
+
{folderPath}
+
+ )} + + {error &&

{error}

} + + + + + + + )} +
+
+ ) +}) + +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'