mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 00:02:41 +00:00
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:
co-authored by
Neil
parent
e330feb131
commit
d314a2bd2e
@@ -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 />
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user