mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Default-driven create-project flow: name-first form with sensible defaults (#5115)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
committed by
Jinwoo-H
co-authored by
Orca
parent
535982dd50
commit
19ee3355ac
@@ -21,6 +21,7 @@ const {
|
||||
readdirMock,
|
||||
rmMock,
|
||||
gitExecFileAsyncMock,
|
||||
homedirMock,
|
||||
invalidateAuthorizedRootsCacheMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
@@ -37,6 +38,7 @@ const {
|
||||
readdirMock: vi.fn(),
|
||||
rmMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
homedirMock: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheMock: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -55,6 +57,10 @@ vi.mock('fs/promises', () => ({
|
||||
rm: rmMock
|
||||
}))
|
||||
|
||||
vi.mock('os', () => ({
|
||||
homedir: homedirMock
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
gitSpawn: vi.fn()
|
||||
@@ -88,7 +94,7 @@ type CreateResult =
|
||||
| { error: string }
|
||||
|
||||
describe('repos:create', () => {
|
||||
const handlers = new Map<string, (event: unknown, args: unknown) => Promise<unknown>>()
|
||||
const handlers = new Map<string, (event: unknown, args: unknown) => unknown>()
|
||||
const mockWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn() }
|
||||
@@ -101,12 +107,19 @@ describe('repos:create', () => {
|
||||
}
|
||||
return handler(null, args) as Promise<CreateResult>
|
||||
}
|
||||
const callDefaultCreateProjectParent = (): Promise<string> => {
|
||||
const handler = handlers.get('repos:getDefaultCreateProjectParent')
|
||||
if (!handler) {
|
||||
throw new Error('repos:getDefaultCreateProjectParent handler was never registered')
|
||||
}
|
||||
return Promise.resolve(handler(null, undefined)).then((value) => value as string)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => {
|
||||
handlers.set(channel, handler as (event: unknown, args: unknown) => Promise<unknown>)
|
||||
handlers.set(channel, handler as (event: unknown, args: unknown) => unknown)
|
||||
})
|
||||
removeHandlerMock.mockReset()
|
||||
mockStore.getRepos.mockReset().mockReturnValue([])
|
||||
@@ -120,6 +133,7 @@ describe('repos:create', () => {
|
||||
mkdirMock.mockReset().mockResolvedValue(undefined)
|
||||
rmMock.mockReset().mockResolvedValue(undefined)
|
||||
gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
homedirMock.mockReset().mockReturnValue('/Users/alice')
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
})
|
||||
@@ -128,11 +142,17 @@ describe('repos:create', () => {
|
||||
expect(handlers.has('repos:create')).toBe(true)
|
||||
})
|
||||
|
||||
it('registers the home-backed create-project default handler', async () => {
|
||||
expect(handlers.has('repos:getDefaultCreateProjectParent')).toBe(true)
|
||||
await expect(callDefaultCreateProjectParent()).resolves.toBe('/Users/alice/orca/projects')
|
||||
})
|
||||
|
||||
it('unregisters any previously-registered repos:create handler', () => {
|
||||
// registerRepoHandlers must call removeHandler('repos:create') before
|
||||
// ipcMain.handle to avoid the "second handler for same channel" throw
|
||||
// when this module is re-registered (e.g., after a reload).
|
||||
expect(removeHandlerMock).toHaveBeenCalledWith('repos:create')
|
||||
expect(removeHandlerMock).toHaveBeenCalledWith('repos:getDefaultCreateProjectParent')
|
||||
})
|
||||
|
||||
// ── input validation ──────────────────────────────────────────────
|
||||
@@ -172,24 +192,26 @@ describe('repos:create', () => {
|
||||
|
||||
// ── existing-directory handling ───────────────────────────────────
|
||||
|
||||
it('rejects a non-empty existing directory without calling mkdir', async () => {
|
||||
it('rejects a non-empty existing directory without creating the target', async () => {
|
||||
accessMock.mockResolvedValueOnce(undefined) // exists
|
||||
readdirMock.mockResolvedValueOnce(['README.md', '.DS_Store'])
|
||||
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'busy', kind: 'git' })
|
||||
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('not empty') })
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
expect(mkdirMock).toHaveBeenCalledWith('/tmp', { recursive: true })
|
||||
expect(mkdirMock).not.toHaveBeenCalledWith('/tmp/busy', expect.anything())
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts an empty existing directory and does not call mkdir', async () => {
|
||||
it('accepts an empty existing directory and does not create the target', async () => {
|
||||
accessMock.mockResolvedValueOnce(undefined) // exists
|
||||
readdirMock.mockResolvedValueOnce([])
|
||||
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'empty', kind: 'folder' })
|
||||
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
expect(mkdirMock).toHaveBeenCalledWith('/tmp', { recursive: true })
|
||||
expect(mkdirMock).not.toHaveBeenCalledWith('/tmp/empty', expect.anything())
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: '/tmp/empty', kind: 'folder' })
|
||||
)
|
||||
@@ -200,7 +222,24 @@ describe('repos:create', () => {
|
||||
// accessMock rejects by default → path does not exist
|
||||
await callCreate({ parentPath: '/tmp', name: 'brand-new', kind: 'folder' })
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith('/tmp/brand-new', { recursive: false })
|
||||
expect(mkdirMock).toHaveBeenNthCalledWith(1, '/tmp', { recursive: true })
|
||||
expect(mkdirMock).toHaveBeenNthCalledWith(2, '/tmp/brand-new', { recursive: false })
|
||||
})
|
||||
|
||||
it('creates a missing default parent before creating the project directory', async () => {
|
||||
const result = await callCreate({
|
||||
parentPath: '/Users/alice/orca/projects',
|
||||
name: 'first-project',
|
||||
kind: 'folder'
|
||||
})
|
||||
|
||||
expect(mkdirMock).toHaveBeenNthCalledWith(1, '/Users/alice/orca/projects', {
|
||||
recursive: true
|
||||
})
|
||||
expect(mkdirMock).toHaveBeenNthCalledWith(2, '/Users/alice/orca/projects/first-project', {
|
||||
recursive: false
|
||||
})
|
||||
expect(result).toHaveProperty('repo.path', '/Users/alice/orca/projects/first-project')
|
||||
})
|
||||
|
||||
// ── plain folder happy path ───────────────────────────────────────
|
||||
@@ -233,7 +272,8 @@ describe('repos:create', () => {
|
||||
it('creates a git repo with an empty initial commit (in order)', async () => {
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'gitproj', kind: 'git' })
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith('/tmp/gitproj', { recursive: false })
|
||||
expect(mkdirMock).toHaveBeenNthCalledWith(1, '/tmp', { recursive: true })
|
||||
expect(mkdirMock).toHaveBeenNthCalledWith(2, '/tmp/gitproj', { recursive: false })
|
||||
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['init'], { cwd: '/tmp/gitproj' })
|
||||
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
|
||||
@@ -4,6 +4,7 @@ boundary. Splitting by line count would scatter tightly coupled repo behavior. *
|
||||
import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'
|
||||
import { dialog, ipcMain } from 'electron'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
import { z } from 'zod'
|
||||
import type { Store } from '../persistence'
|
||||
import type {
|
||||
@@ -579,6 +580,7 @@ type CompletedNestedRepoScan = {
|
||||
}
|
||||
const completedNestedRepoScans = new Map<string, CompletedNestedRepoScan>()
|
||||
const MAX_COMPLETED_NESTED_SCAN_RESULTS = 50
|
||||
const GIT_AVAILABILITY_TIMEOUT_MS = 1500
|
||||
|
||||
function emitCloneProgressFromText(mainWindow: BrowserWindow, text: string): void {
|
||||
for (const line of text.split(/[\r\n]+/)) {
|
||||
@@ -784,6 +786,22 @@ async function cleanupOwnedCloneTarget(metadata: ActiveCloneMetadata): Promise<v
|
||||
await cleanupClaimedCloneTarget(metadata.path, metadata.claimedTarget)
|
||||
}
|
||||
|
||||
async function isGitAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await gitExecFileAsync(['--version'], {
|
||||
cwd: process.cwd(),
|
||||
timeout: GIT_AVAILABILITY_TIMEOUT_MS
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultCreateProjectParent(): string {
|
||||
return join(homedir(), 'orca', 'projects')
|
||||
}
|
||||
|
||||
function markCloneAbortCleanupPending(metadata: ActiveCloneMetadata): void {
|
||||
if (metadata.resolvePendingAbortCleanup) {
|
||||
return
|
||||
@@ -975,6 +993,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
ipcMain.removeHandler('repos:clone')
|
||||
ipcMain.removeHandler('repos:cloneAbort')
|
||||
ipcMain.removeHandler('repos:cloneRemote')
|
||||
ipcMain.removeHandler('repos:isGitAvailable')
|
||||
ipcMain.removeHandler('repos:getDefaultCreateProjectParent')
|
||||
ipcMain.removeHandler('repos:getGitUsername')
|
||||
ipcMain.removeHandler('repos:getBaseRefDefault')
|
||||
ipcMain.removeHandler('repos:searchBaseRefs')
|
||||
@@ -1089,6 +1109,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('repos:isGitAvailable', () => isGitAvailable())
|
||||
ipcMain.handle('repos:getDefaultCreateProjectParent', () => getDefaultCreateProjectParent())
|
||||
|
||||
ipcMain.handle('projectGroups:list', () => store.getProjectGroups())
|
||||
|
||||
ipcMain.handle('projectGroups:create', (_event, rawArgs: unknown): ProjectGroup => {
|
||||
@@ -1408,6 +1431,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
let createdDir = false
|
||||
let targetExists = false
|
||||
try {
|
||||
// Why: the name-first default points at ~/orca/projects, which may not
|
||||
// exist yet on a fresh install; create only the parent before probing target.
|
||||
await mkdir(parentPath, { recursive: true })
|
||||
await access(targetPath)
|
||||
targetExists = true
|
||||
} catch (err) {
|
||||
|
||||
@@ -3180,6 +3180,33 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a missing runtime parent before creating the project directory', async () => {
|
||||
const added: Record<string, unknown>[] = []
|
||||
const createStore = {
|
||||
...store,
|
||||
getRepos: () => [...added] as never,
|
||||
addRepo: (repo: Record<string, unknown>) => {
|
||||
added.push(repo)
|
||||
},
|
||||
getRepo: (id: string) => added.find((repo) => repo.id === id) as never
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(createStore as never)
|
||||
const tempRoot = await mkdtemp('/tmp/orca-runtime-create-parent-')
|
||||
const parentDir = join(tempRoot, 'orca', 'projects')
|
||||
try {
|
||||
const result = await runtime.createRepo(parentDir, 'first-project', 'folder')
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
expect((await lstat(parentDir)).isDirectory()).toBe(true)
|
||||
expect((await lstat(join(parentDir, 'first-project'))).isDirectory()).toBe(true)
|
||||
expect(result).toHaveProperty('repo.path', join(parentDir, 'first-project'))
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves existing badgeColor on runtime createRepo dedupe', async () => {
|
||||
const existing = {
|
||||
id: 'runtime-existing-create',
|
||||
|
||||
@@ -6650,6 +6650,15 @@ export class OrcaRuntimeService {
|
||||
return { resolvedPath: dirPath, entries: mapped }
|
||||
}
|
||||
|
||||
async isGitAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await gitExecFileAsync(['--version'], { cwd: process.cwd(), timeout: 3000 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async importNestedRepos(args: {
|
||||
parentPath: string
|
||||
groupName: string
|
||||
@@ -6847,6 +6856,9 @@ export class OrcaRuntimeService {
|
||||
|
||||
let createdDir = false
|
||||
try {
|
||||
// Why: default create-project parents are host-home based and may not exist
|
||||
// before the first project is created on a fresh runtime.
|
||||
await mkdir(trimmedParentPath, { recursive: true })
|
||||
const existingStat = await stat(targetPath).catch((error: unknown) => {
|
||||
if (isENOENT(error)) {
|
||||
return null
|
||||
|
||||
@@ -33,6 +33,22 @@ describe('repo RPC methods', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports runtime Git availability without exposing command details', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
isGitAvailable: vi.fn().mockResolvedValue(true)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('repo.gitAvailable'))
|
||||
|
||||
expect(runtime.isGitAvailable).toHaveBeenCalled()
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { available: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('clones a repo on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -230,6 +230,11 @@ export const REPO_METHODS: RpcMethod[] = [
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.createRepo(params.parentPath, params.name, params.kind)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.gitAvailable',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }) => ({ available: await runtime.isGitAvailable() })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.clone',
|
||||
params: RepoClone,
|
||||
|
||||
@@ -251,6 +251,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'preflight.detectAgents',
|
||||
'preflight.detectRemoteAgents',
|
||||
'repo.baseRefDefault',
|
||||
'repo.gitAvailable',
|
||||
'repo.hooks',
|
||||
'repo.list',
|
||||
'repo.saveSparsePreset',
|
||||
|
||||
@@ -782,6 +782,8 @@ export type PreloadApi = {
|
||||
name: string
|
||||
kind: 'git' | 'folder'
|
||||
}) => Promise<{ repo: Repo } | { error: string }>
|
||||
isGitAvailable: () => Promise<boolean>
|
||||
getDefaultCreateProjectParent: () => Promise<string>
|
||||
onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void
|
||||
getGitUsername: (args: { repoId: string }) => Promise<string>
|
||||
getBaseRefDefault: (args: { repoId: string }) => Promise<BaseRefDefaultResult>
|
||||
|
||||
@@ -460,6 +460,11 @@ const api = {
|
||||
|
||||
create: (args) => ipcRenderer.invoke('repos:create', args),
|
||||
|
||||
isGitAvailable: (): Promise<boolean> => ipcRenderer.invoke('repos:isGitAvailable'),
|
||||
|
||||
getDefaultCreateProjectParent: (): Promise<string> =>
|
||||
ipcRenderer.invoke('repos:getDefaultCreateProjectParent'),
|
||||
|
||||
remove: (args) => ipcRenderer.invoke('repos:remove', args),
|
||||
|
||||
reorder: (args) => ipcRenderer.invoke('repos:reorder', args),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Dialog } from '@/components/ui/dialog'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { CreateStep } from './AddRepoCreateStep'
|
||||
import type { GitAvailability, RepoKind } from './create-project-defaults'
|
||||
|
||||
function renderCreateStep({
|
||||
createName = '',
|
||||
createKind = 'git',
|
||||
gitAvailability = 'available',
|
||||
createParent = '/Users/alice/orca/projects',
|
||||
parentDefaultPending = false
|
||||
}: {
|
||||
createName?: string
|
||||
createKind?: RepoKind
|
||||
gitAvailability?: GitAvailability
|
||||
createParent?: string
|
||||
parentDefaultPending?: boolean
|
||||
} = {}): string {
|
||||
return renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<Dialog open>
|
||||
<CreateStep
|
||||
createName={createName}
|
||||
createParent={createParent}
|
||||
createKind={createKind}
|
||||
createError={null}
|
||||
isCreating={false}
|
||||
defaultParent="/Users/alice/orca/projects"
|
||||
gitAvailability={gitAvailability}
|
||||
runtimeParentStatus="idle"
|
||||
parentDefaultPending={parentDefaultPending}
|
||||
onNameChange={vi.fn()}
|
||||
onParentChange={vi.fn()}
|
||||
onKindChange={vi.fn()}
|
||||
onPickParent={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
/>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('CreateStep', () => {
|
||||
it('renders the name-first create UI with advanced controls collapsed', () => {
|
||||
const html = renderCreateStep()
|
||||
|
||||
expect(html).toContain('Create a new project')
|
||||
expect(html).toContain('Name')
|
||||
expect(html).toContain('Git repository in ~/orca/projects')
|
||||
// The summary card itself is the collapsed disclosure for the uncommon settings.
|
||||
expect(html).toContain('aria-expanded="false"')
|
||||
expect(html).not.toContain('Project kind')
|
||||
expect(html).not.toContain('Location</span>')
|
||||
expect(html).not.toContain('aria-label="Browse server filesystem"')
|
||||
})
|
||||
|
||||
it('shows the Git fallback explanation in the collapsed summary', () => {
|
||||
const html = renderCreateStep({ createKind: 'folder', gitAvailability: 'unavailable' })
|
||||
|
||||
expect(html).toContain('Folder in ~/orca/projects')
|
||||
expect(html).toContain('Git isn't installed, so a plain folder is the default.')
|
||||
})
|
||||
|
||||
it('disables create while an auto-filled parent belongs to a previous target', () => {
|
||||
const html = renderCreateStep({
|
||||
createName: 'demo-project',
|
||||
parentDefaultPending: true
|
||||
})
|
||||
|
||||
expect(html).toContain('disabled=""')
|
||||
})
|
||||
})
|
||||
@@ -1,250 +1,25 @@
|
||||
// Step for AddRepoDialog (orca#763), split out so create-project state stays scoped.
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Folder, GitBranch } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, Folder, GitBranch, Loader2 } from 'lucide-react'
|
||||
import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
CreateProjectLocationField,
|
||||
CreateProjectParentBrowser
|
||||
} from './CreateProjectLocationField'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { AddRepoCreateKindCard, type AddRepoCreateKind } from './AddRepoCreateKindCard'
|
||||
import {
|
||||
formatCreateProjectParentSummary,
|
||||
joinCreateProjectPath,
|
||||
type GitAvailability,
|
||||
type RepoKind
|
||||
} from './create-project-defaults'
|
||||
|
||||
type RepoKind = AddRepoCreateKind
|
||||
// ── UI helpers ───────────────────────────────────────────────────────
|
||||
|
||||
export function useCreateRepo(
|
||||
fetchWorktrees: (
|
||||
repoId: string,
|
||||
options?: { requireAuthoritative?: boolean }
|
||||
) => Promise<boolean>,
|
||||
closeModal: () => void,
|
||||
onGitRepoReady?: (repoId: string) => void | Promise<void>,
|
||||
options: { hostId?: string | null; sshTargetId?: string | null } = {}
|
||||
) {
|
||||
const [createName, setCreateName] = useState('')
|
||||
const [createParent, setCreateParent] = useState('')
|
||||
const [createKind, setCreateKind] = useState<RepoKind>('git')
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const mountedRef = useMountedRef()
|
||||
const hostToken = options.hostId ?? options.sshTargetId ?? ''
|
||||
const hostTokenRef = useRef(hostToken)
|
||||
hostTokenRef.current = hostToken
|
||||
|
||||
// Why: monotonic ID so stale create callbacks can detect they were superseded
|
||||
// when the user clicks Back or closes the dialog mid-create. Mirrors the
|
||||
// cloneGenRef pattern in AddRepoDialog.
|
||||
const createGenRef = useRef(0)
|
||||
|
||||
const resetCreateState = useCallback(() => {
|
||||
createGenRef.current++
|
||||
setCreateName('')
|
||||
setCreateParent('')
|
||||
setCreateKind('git')
|
||||
setCreateError(null)
|
||||
setIsCreating(false)
|
||||
}, [])
|
||||
|
||||
const handlePickParent = useCallback(async () => {
|
||||
if (options.sshTargetId) {
|
||||
// Why: the native picker can only browse the client machine. SSH create
|
||||
// uses a host path typed by the user until remote folder picking exists.
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.ssh_parent_manual',
|
||||
'Enter an SSH parent path.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) {
|
||||
// Why: the native folder picker returns a client-local path. Runtime
|
||||
// project creation needs an explicit server parent path.
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.875dda0995',
|
||||
'Enter a server parent path.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
const gen = createGenRef.current
|
||||
const dir = await window.api.repos.pickDirectory()
|
||||
if (dir && gen === createGenRef.current && mountedRef.current) {
|
||||
setCreateParent(dir)
|
||||
setCreateError(null)
|
||||
}
|
||||
}, [mountedRef, options.sshTargetId])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
const name = createName.trim()
|
||||
const parentPath = createParent.trim()
|
||||
if (!name || !parentPath) {
|
||||
return
|
||||
}
|
||||
const requestHostToken = hostTokenRef.current
|
||||
const gen = ++createGenRef.current
|
||||
setIsCreating(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(useAppStore.getState().settings)
|
||||
const result = options.sshTargetId
|
||||
? await window.api.repos.createRemote({
|
||||
connectionId: options.sshTargetId,
|
||||
parentPath,
|
||||
name,
|
||||
kind: createKind
|
||||
})
|
||||
: target.kind === 'environment'
|
||||
? await callRuntimeRpc<{ repo: Repo } | { error: string }>(
|
||||
target,
|
||||
'repo.create',
|
||||
{
|
||||
parentPath,
|
||||
name,
|
||||
kind: createKind
|
||||
},
|
||||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
: await window.api.repos.create({
|
||||
parentPath,
|
||||
name,
|
||||
kind: createKind
|
||||
})
|
||||
// Why: if the user closed the dialog or clicked Back mid-create,
|
||||
// createGenRef was bumped by resetCreateState. Ignore stale results.
|
||||
if (
|
||||
gen !== createGenRef.current ||
|
||||
requestHostToken !== hostTokenRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
if ('error' in result) {
|
||||
setCreateError(result.error)
|
||||
return
|
||||
}
|
||||
const repo = result.repo
|
||||
// Upsert into the store before the repos:changed event round-trips,
|
||||
// so the next step can find the repo immediately.
|
||||
const state = useAppStore.getState()
|
||||
const existingIdx = state.repos.findIndex((r) => r.id === repo.id)
|
||||
// Why: the IPC handler dedupes by path (see repos:create) and returns
|
||||
// the existing repo unchanged. If its ID is already in our store, the
|
||||
// handler took the dedup path — no new project was created, so don't
|
||||
// claim one was.
|
||||
const wasDeduped = existingIdx !== -1
|
||||
if (existingIdx === -1) {
|
||||
useAppStore.setState({ repos: [...state.repos, repo] })
|
||||
} else {
|
||||
const updated = [...state.repos]
|
||||
updated[existingIdx] = repo
|
||||
useAppStore.setState({ repos: updated })
|
||||
}
|
||||
if (wasDeduped) {
|
||||
toast.info(
|
||||
translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.2c12db1511',
|
||||
'Project already added'
|
||||
),
|
||||
{
|
||||
description: repo.displayName
|
||||
}
|
||||
)
|
||||
} else {
|
||||
toast.success(
|
||||
translate('auto.components.sidebar.AddRepoCreateStep.5e97f0c4b9', 'Project created'),
|
||||
{
|
||||
description: repo.displayName
|
||||
}
|
||||
)
|
||||
}
|
||||
if (isGitRepoKind(repo)) {
|
||||
// Why: Git repos use the shared default-checkout completion path.
|
||||
// Why: if refresh is temporarily non-authoritative, the shared opener
|
||||
// still reveals the project so the user is not left in a completed add flow.
|
||||
await fetchWorktrees(repo.id, { requireAuthoritative: true })
|
||||
if (
|
||||
gen !== createGenRef.current ||
|
||||
requestHostToken !== hostTokenRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
await onGitRepoReady?.(repo.id)
|
||||
} else {
|
||||
// Why: folder repos skip the Git default-checkout handoff, so activate the synthetic
|
||||
// root workspace before closing. Matches addNonGitFolder's behavior.
|
||||
await fetchWorktrees(repo.id)
|
||||
if (
|
||||
gen !== createGenRef.current ||
|
||||
requestHostToken !== hostTokenRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
const folderWorktree = useAppStore.getState().worktreesByRepo[repo.id]?.[0]
|
||||
if (folderWorktree) {
|
||||
activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto' })
|
||||
}
|
||||
await markOnboardingProjectAdded('addedFolder')
|
||||
closeModal()
|
||||
}
|
||||
} catch (err) {
|
||||
if (
|
||||
gen !== createGenRef.current ||
|
||||
requestHostToken !== hostTokenRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
setCreateError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
// Why: only clear the loading state if this invocation is still current;
|
||||
// a superseded create must not flip the flag back off for a new flow.
|
||||
if (
|
||||
gen === createGenRef.current &&
|
||||
requestHostToken === hostTokenRef.current &&
|
||||
mountedRef.current
|
||||
) {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
createName,
|
||||
createParent,
|
||||
createKind,
|
||||
fetchWorktrees,
|
||||
mountedRef,
|
||||
closeModal,
|
||||
onGitRepoReady,
|
||||
options.sshTargetId
|
||||
])
|
||||
|
||||
return {
|
||||
createName,
|
||||
createParent,
|
||||
createKind,
|
||||
createError,
|
||||
isCreating,
|
||||
setCreateName,
|
||||
setCreateParent,
|
||||
setCreateKind,
|
||||
setCreateError,
|
||||
resetCreateState,
|
||||
handlePickParent,
|
||||
handleCreate
|
||||
}
|
||||
}
|
||||
const CREATE_PROJECT_NAME_PLACEHOLDER = 'project-name'
|
||||
|
||||
type CreateStepProps = {
|
||||
createName: string
|
||||
@@ -252,6 +27,10 @@ type CreateStepProps = {
|
||||
createKind: RepoKind
|
||||
createError: string | null
|
||||
isCreating: boolean
|
||||
defaultParent?: string
|
||||
gitAvailability?: GitAvailability
|
||||
runtimeParentStatus?: 'idle' | 'checking' | 'failed'
|
||||
parentDefaultPending?: boolean
|
||||
manualParentEntry?: boolean
|
||||
runtimeEnvironmentId?: string | null
|
||||
onNameChange: (value: string) => void
|
||||
@@ -267,6 +46,10 @@ export function CreateStep({
|
||||
createKind,
|
||||
createError,
|
||||
isCreating,
|
||||
defaultParent = '',
|
||||
gitAvailability = 'unknown',
|
||||
runtimeParentStatus = 'idle',
|
||||
parentDefaultPending = false,
|
||||
manualParentEntry = false,
|
||||
runtimeEnvironmentId,
|
||||
onNameChange,
|
||||
@@ -278,6 +61,7 @@ export function CreateStep({
|
||||
const radioGroupRef = useRef<HTMLDivElement>(null)
|
||||
const radioFocusFrameRef = useRef<number | null>(null)
|
||||
const [browsingParent, setBrowsingParent] = useState(false)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
const cancelRadioFocusFrame = useCallback((): void => {
|
||||
if (radioFocusFrameRef.current === null) {
|
||||
@@ -312,7 +96,50 @@ export function CreateStep({
|
||||
})
|
||||
}, [cancelRadioFocusFrame, createKind, onKindChange])
|
||||
|
||||
const canSubmit = createName.trim().length > 0 && createParent.trim().length > 0 && !isCreating
|
||||
const canSubmit =
|
||||
createName.trim().length > 0 &&
|
||||
createParent.trim().length > 0 &&
|
||||
gitAvailability !== 'checking' &&
|
||||
!parentDefaultPending &&
|
||||
!isCreating
|
||||
const missingLocationLabel = translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.3a13f6e88b',
|
||||
'location not selected'
|
||||
)
|
||||
const missingServerLocationLabel = translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.6ed14c0281',
|
||||
'server folder not selected'
|
||||
)
|
||||
|
||||
const summaryParent = useMemo(
|
||||
() =>
|
||||
formatCreateProjectParentSummary({
|
||||
parent: createParent,
|
||||
defaultParent,
|
||||
runtimeEnvironmentId,
|
||||
missingLocationLabel,
|
||||
missingServerLocationLabel
|
||||
}),
|
||||
[
|
||||
createParent,
|
||||
defaultParent,
|
||||
missingLocationLabel,
|
||||
missingServerLocationLabel,
|
||||
runtimeEnvironmentId
|
||||
]
|
||||
)
|
||||
const targetPathPreview = useMemo(() => {
|
||||
const name = createName.trim() || CREATE_PROJECT_NAME_PLACEHOLDER
|
||||
return createParent.trim() ? joinCreateProjectPath(createParent, name) : ''
|
||||
}, [createName, createParent])
|
||||
const kindLabel =
|
||||
createKind === 'git'
|
||||
? translate('auto.components.sidebar.AddRepoCreateStep.11fd2a7db8', 'Git repository')
|
||||
: translate('auto.components.sidebar.AddRepoCreateStep.038729c107', 'Folder')
|
||||
const showGitFallback = gitAvailability === 'unavailable'
|
||||
const showGitChecking = gitAvailability === 'checking'
|
||||
const showRuntimeMissingParent =
|
||||
runtimeEnvironmentId && !createParent.trim() && runtimeParentStatus !== 'checking'
|
||||
|
||||
if (browsingParent && runtimeEnvironmentId) {
|
||||
return (
|
||||
@@ -329,12 +156,15 @@ export function CreateStep({
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate('auto.components.sidebar.AddRepoCreateStep.db9be12229', 'Start a new project')}
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.c7b9f94456',
|
||||
'Create a new project'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.d877ece0d6',
|
||||
'Create a Git repository or a plain folder and open it in Orca.'
|
||||
'auto.components.sidebar.AddRepoCreateStep.b100311784',
|
||||
'Name it and Orca will create a real project with sensible defaults.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -344,41 +174,6 @@ export function CreateStep({
|
||||
the dialog width even with flex + truncate on the row itself. min-w-0
|
||||
here caps the grid track at the dialog's max-width. */}
|
||||
<div className="space-y-3.5 pt-1 min-w-0">
|
||||
{/* Kind toggle. Real radiogroup so screen readers announce it as a choice. */}
|
||||
<div
|
||||
ref={setRadioGroupNode}
|
||||
role="radiogroup"
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.180e9b5e48',
|
||||
'Project kind'
|
||||
)}
|
||||
className="grid grid-cols-2 gap-2"
|
||||
>
|
||||
<AddRepoCreateKindCard
|
||||
kind="git"
|
||||
selected={createKind === 'git'}
|
||||
disabled={isCreating}
|
||||
onSelect={() => onKindChange('git')}
|
||||
onArrowNav={cycleKind}
|
||||
icon={<GitBranch className="size-4" />}
|
||||
title={translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.11fd2a7db8',
|
||||
'Git repository'
|
||||
)}
|
||||
caption="Initializes an empty Git repo"
|
||||
/>
|
||||
<AddRepoCreateKindCard
|
||||
kind="folder"
|
||||
selected={createKind === 'folder'}
|
||||
disabled={isCreating}
|
||||
onSelect={() => onKindChange('folder')}
|
||||
onArrowNav={cycleKind}
|
||||
icon={<Folder className="size-4" />}
|
||||
title={translate('auto.components.sidebar.AddRepoCreateStep.038729c107', 'Folder')}
|
||||
caption="Create a new folder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name. Monospaced because it ends up as a directory name. */}
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
@@ -403,16 +198,174 @@ export function CreateStep({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The local picker returns client paths; runtime servers browse host paths via RPC. */}
|
||||
<CreateProjectLocationField
|
||||
createParent={createParent}
|
||||
isCreating={isCreating}
|
||||
manualParentEntry={manualParentEntry}
|
||||
runtimeEnvironmentId={runtimeEnvironmentId}
|
||||
onParentChange={onParentChange}
|
||||
onPickParent={onPickParent}
|
||||
onBrowseServer={() => setBrowsingParent(true)}
|
||||
/>
|
||||
{/* Summary card doubles as the disclosure for the uncommon settings, so the
|
||||
defaults and the controls to change them live in one place. */}
|
||||
<div className="min-w-0 rounded-md border border-border bg-muted/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
aria-expanded={advancedOpen}
|
||||
className="flex w-full min-w-0 items-start gap-2.5 rounded-md px-3 py-2.5 text-left transition-colors cursor-pointer hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<span className="mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-background/60 text-muted-foreground">
|
||||
{createKind === 'git' ? (
|
||||
<GitBranch className="size-3.5" />
|
||||
) : (
|
||||
<Folder className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.685b5eefe1',
|
||||
'{{kind}} in {{parent}}',
|
||||
{
|
||||
kind: kindLabel,
|
||||
parent: summaryParent
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
{showGitChecking ? (
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.2a762f3b19',
|
||||
'Checking Git on this host...'
|
||||
)}
|
||||
</p>
|
||||
) : showGitFallback ? (
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.fe1e616c5b',
|
||||
"Git isn't installed, so a plain folder is the default."
|
||||
)}
|
||||
</p>
|
||||
) : showRuntimeMissingParent ? (
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.c234df77f7',
|
||||
'Choose or enter a server parent folder before creating.'
|
||||
)}
|
||||
</p>
|
||||
) : targetPathPreview ? (
|
||||
<p
|
||||
className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground"
|
||||
title={targetPathPreview}
|
||||
>
|
||||
{targetPathPreview}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 self-center text-muted-foreground transition-transform',
|
||||
advancedOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="space-y-3 border-t border-border px-3 py-3">
|
||||
{/* Real radiogroup so screen readers announce the segmented choice. */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[11px] font-medium text-muted-foreground block">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.180e9b5e48',
|
||||
'Project kind'
|
||||
)}
|
||||
</span>
|
||||
<div
|
||||
ref={setRadioGroupNode}
|
||||
role="radiogroup"
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.180e9b5e48',
|
||||
'Project kind'
|
||||
)}
|
||||
className="grid grid-cols-2 rounded-md border border-border bg-muted/30 p-0.5"
|
||||
>
|
||||
{(['git', 'folder'] as const).map((kind) => {
|
||||
const selected = createKind === kind
|
||||
const label =
|
||||
kind === 'git'
|
||||
? translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.11fd2a7db8',
|
||||
'Git repository'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.038729c107',
|
||||
'Folder'
|
||||
)
|
||||
const Icon = kind === 'git' ? GitBranch : Folder
|
||||
return (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onClick={() => onKindChange(kind)}
|
||||
onKeyDown={(e) => {
|
||||
// Why: keep keyboard radio navigation intact inside the compact segmented control.
|
||||
if (
|
||||
e.key === 'ArrowLeft' ||
|
||||
e.key === 'ArrowRight' ||
|
||||
e.key === 'ArrowUp' ||
|
||||
e.key === 'ArrowDown'
|
||||
) {
|
||||
e.preventDefault()
|
||||
cycleKind()
|
||||
} else if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onKindChange(kind)
|
||||
}
|
||||
}}
|
||||
disabled={isCreating}
|
||||
data-kind={kind}
|
||||
className={cn(
|
||||
'inline-flex min-w-0 items-center justify-center gap-1.5 rounded-sm border px-2.5 py-2 text-xs font-medium outline-none transition-colors',
|
||||
// Why: the segment sits on a muted card, so bg-background alone
|
||||
// is too subtle; the border makes the selected state legible.
|
||||
selected
|
||||
? 'border-border bg-background text-foreground shadow-xs'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
'focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60'
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{showGitFallback && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.fe1e616c5b',
|
||||
"Git isn't installed, so a plain folder is the default."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The local picker returns client paths; runtime servers browse host paths via RPC. */}
|
||||
<CreateProjectLocationField
|
||||
createParent={createParent}
|
||||
isCreating={isCreating}
|
||||
manualParentEntry={manualParentEntry}
|
||||
runtimeEnvironmentId={runtimeEnvironmentId}
|
||||
onParentChange={onParentChange}
|
||||
onPickParent={onPickParent}
|
||||
onBrowseServer={() => setBrowsingParent(true)}
|
||||
/>
|
||||
|
||||
{targetPathPreview && (
|
||||
<p className="min-w-0 break-all rounded-md border border-border bg-background/40 px-2.5 py-2 font-mono text-[11px] text-muted-foreground">
|
||||
{targetPathPreview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<p className="text-[11px] text-destructive" role="alert">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useAppStore } from '@/store'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { useRemoteRepo } from './AddRepoSteps'
|
||||
import { useCreateRepo } from './AddRepoCreateStep'
|
||||
import { useCreateRepo } from './useCreateRepo'
|
||||
import { buildNestedRepoScanTelemetry } from '../../../../shared/nested-repo-telemetry'
|
||||
import { AddRepoStepIndicator } from './AddRepoStepIndicator'
|
||||
import { AddRepoDialogStepContent } from './AddRepoDialogStepContent'
|
||||
@@ -16,6 +16,7 @@ import { useAddRepoNestedImportFlow } from './useAddRepoNestedImportFlow'
|
||||
import { AddRepoHostSelector } from './AddRepoHostSelector'
|
||||
import { useAddRepoHostSelection } from './use-add-repo-host-selection'
|
||||
import { useCompleteGitRepoAdd } from './use-complete-git-repo-add'
|
||||
import { useCreateProjectDefaults } from './useCreateProjectDefaults'
|
||||
|
||||
const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
@@ -139,6 +140,22 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
{ hostId: selectedHostId, sshTargetId: selectedSshTargetId }
|
||||
)
|
||||
|
||||
const {
|
||||
createDefaultParent,
|
||||
createGitAvailability,
|
||||
createRuntimeParentStatus,
|
||||
createParentDefaultPending,
|
||||
resetCreateDefaultState,
|
||||
markCreateParentTouched,
|
||||
markCreateKindTouched
|
||||
} = useCreateProjectDefaults({
|
||||
step,
|
||||
activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId,
|
||||
createParent,
|
||||
setCreateParent,
|
||||
setCreateKind
|
||||
})
|
||||
|
||||
const {
|
||||
cloneUrl,
|
||||
cloneDestination,
|
||||
@@ -226,12 +243,14 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
resetCloneFlow()
|
||||
resetNestedImportFlow()
|
||||
resetNestedRepoReviewState()
|
||||
resetCreateDefaultState()
|
||||
resetCreateState()
|
||||
resetRemoteState()
|
||||
}, [
|
||||
resetCloneFlow,
|
||||
resetLocalFolderFlow,
|
||||
resetNestedRepoReviewState,
|
||||
resetCreateDefaultState,
|
||||
resetServerPathFlow,
|
||||
resetNestedImportFlow,
|
||||
resetRemoteState,
|
||||
@@ -315,6 +334,10 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
isCreating={isCreating}
|
||||
hostSelector={hostSelector}
|
||||
showRemoteAction={false}
|
||||
createDefaultParent={createDefaultParent}
|
||||
createGitAvailability={createGitAvailability}
|
||||
createRuntimeParentStatus={createRuntimeParentStatus}
|
||||
createParentDefaultPending={createParentDefaultPending}
|
||||
manualCreateParentEntry={isRuntimeEnvironmentActive || selectedParsedHost?.kind === 'ssh'}
|
||||
onBrowse={
|
||||
selectedParsedHost?.kind === 'ssh'
|
||||
@@ -367,14 +390,22 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
setCreateError(null)
|
||||
}}
|
||||
onCreateParentChange={(value) => {
|
||||
markCreateParentTouched(value)
|
||||
setCreateParent(value)
|
||||
setCreateError(null)
|
||||
}}
|
||||
onCreateKindChange={(kind) => {
|
||||
markCreateKindTouched()
|
||||
setCreateKind(kind)
|
||||
setCreateError(null)
|
||||
}}
|
||||
onPickCreateParent={handlePickParent}
|
||||
onPickCreateParent={() => {
|
||||
void handlePickParent().then((dir) => {
|
||||
if (dir) {
|
||||
markCreateParentTouched(dir)
|
||||
}
|
||||
})
|
||||
}}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
@@ -56,6 +56,10 @@ function renderStepContent(overrides: Partial<StepContentProps>): string {
|
||||
createKind: 'git',
|
||||
createError: null,
|
||||
isCreating: false,
|
||||
createDefaultParent: '',
|
||||
createGitAvailability: 'unknown',
|
||||
createRuntimeParentStatus: 'idle',
|
||||
createParentDefaultPending: false,
|
||||
onBrowse: vi.fn(),
|
||||
onOpenCloneStep: vi.fn(),
|
||||
onOpenCreateStep: vi.fn(),
|
||||
@@ -123,8 +127,8 @@ describe('AddRepoDialogStepContent nested imports', () => {
|
||||
activeRuntimeEnvironmentId: 'env-1'
|
||||
})
|
||||
|
||||
expect(html).toContain('Start a new project')
|
||||
expect(html).toContain('aria-label="Browse server filesystem"')
|
||||
expect(html).toContain('Create a new project')
|
||||
expect(html).toContain('server folder not selected')
|
||||
})
|
||||
|
||||
it('uses manual path entry for SSH create project locations', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AddRepoNestedImportStep } from './AddRepoNestedImportStep'
|
||||
import type { AddRepoDialogStep } from './add-repo-dialog-types'
|
||||
import type { NestedRepoScanResult } from '../../../../shared/types'
|
||||
import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
|
||||
import type { GitAvailability } from './create-project-defaults'
|
||||
|
||||
type AddRepoDialogStepContentProps = {
|
||||
step: AddRepoDialogStep
|
||||
@@ -44,6 +45,10 @@ type AddRepoDialogStepContentProps = {
|
||||
showRemoteAction?: boolean
|
||||
canCreateProject?: boolean
|
||||
manualCreateParentEntry?: boolean
|
||||
createDefaultParent: string
|
||||
createGitAvailability: GitAvailability
|
||||
createRuntimeParentStatus: 'idle' | 'checking' | 'failed'
|
||||
createParentDefaultPending: boolean
|
||||
onBrowse: () => void
|
||||
onOpenCloneStep: () => void
|
||||
onOpenCreateStep: () => void
|
||||
@@ -106,6 +111,10 @@ export function AddRepoDialogStepContent({
|
||||
showRemoteAction = true,
|
||||
canCreateProject = true,
|
||||
manualCreateParentEntry = isRuntimeEnvironmentActive,
|
||||
createDefaultParent,
|
||||
createGitAvailability,
|
||||
createRuntimeParentStatus,
|
||||
createParentDefaultPending,
|
||||
onBrowse,
|
||||
onOpenCloneStep,
|
||||
onOpenCreateStep,
|
||||
@@ -231,6 +240,10 @@ export function AddRepoDialogStepContent({
|
||||
createKind={createKind}
|
||||
createError={createError}
|
||||
isCreating={isCreating}
|
||||
defaultParent={createDefaultParent}
|
||||
gitAvailability={createGitAvailability}
|
||||
runtimeParentStatus={createRuntimeParentStatus}
|
||||
parentDefaultPending={createParentDefaultPending}
|
||||
manualParentEntry={manualCreateParentEntry}
|
||||
runtimeEnvironmentId={activeRuntimeEnvironmentId}
|
||||
onNameChange={onCreateNameChange}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Folder, FolderOpen, Home, Pencil } from 'lucide-react'
|
||||
import { Folder, FolderOpen, Pencil } from 'lucide-react'
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -113,9 +113,6 @@ export function CreateProjectLocationField({
|
||||
</div>
|
||||
) : createParent ? (
|
||||
<div className="group flex items-center gap-2.5 rounded-md border border-border bg-background/40 h-11 min-w-0 px-3 text-sm">
|
||||
<span className="shrink-0 inline-flex items-center justify-center size-7 rounded-md border border-border/70 bg-background/50 text-muted-foreground">
|
||||
<Home className="size-3.5" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-[12px]" title={createParent}>
|
||||
{createParent}
|
||||
</span>
|
||||
|
||||
@@ -60,6 +60,20 @@ export function buildAddRepoExistingWorkspacesTelemetry(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAddRepoExistingWorkspacesDetectedEvent(
|
||||
source: AddRepoExistingWorkspaceSource,
|
||||
worktrees: readonly Worktree[]
|
||||
): ExistingWorkspacesDetectedProps | null {
|
||||
const sortedWorktrees = [...worktrees].sort((a, b) => {
|
||||
if (a.lastActivityAt !== b.lastActivityAt) {
|
||||
return b.lastActivityAt - a.lastActivityAt
|
||||
}
|
||||
return a.displayName.localeCompare(b.displayName)
|
||||
})
|
||||
const payload = buildAddRepoExistingWorkspacesTelemetry(source, sortedWorktrees)
|
||||
return payload && shouldTrackAddRepoExistingWorkspacesDetected(payload) ? payload : null
|
||||
}
|
||||
|
||||
export function shouldTrackAddRepoExistingWorkspacesDetected(
|
||||
payload: ExistingWorkspacesDetectedProps | null
|
||||
): boolean {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatCreateProjectParentSummary,
|
||||
getCreateProjectDefaultParentAutoFill,
|
||||
getDefaultCreateProjectParent,
|
||||
joinCreateProjectPath
|
||||
} from './create-project-defaults'
|
||||
|
||||
describe('create project defaults', () => {
|
||||
it('builds the POSIX default project parent', () => {
|
||||
expect(getDefaultCreateProjectParent('/Users/alice')).toBe('/Users/alice/orca/projects')
|
||||
})
|
||||
|
||||
it('builds the Windows default project parent', () => {
|
||||
expect(getDefaultCreateProjectParent('C:\\Users\\alice')).toBe(
|
||||
'C:\\Users\\alice\\orca\\projects'
|
||||
)
|
||||
})
|
||||
|
||||
it('derives the runtime project default from a resolved server home', () => {
|
||||
expect(getDefaultCreateProjectParent('/home/alice')).toBe('/home/alice/orca/projects')
|
||||
})
|
||||
|
||||
it('joins path previews without mixing separators', () => {
|
||||
expect(joinCreateProjectPath('/home/alice/orca/projects', 'demo')).toBe(
|
||||
'/home/alice/orca/projects/demo'
|
||||
)
|
||||
expect(joinCreateProjectPath('C:\\Users\\alice\\orca\\projects', 'demo')).toBe(
|
||||
'C:\\Users\\alice\\orca\\projects\\demo'
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-fills only the first empty local create step', () => {
|
||||
expect(
|
||||
getCreateProjectDefaultParentAutoFill({
|
||||
step: 'create',
|
||||
createParent: '',
|
||||
activeRuntimeEnvironmentId: null,
|
||||
defaultParent: '/Users/alice/orca/projects',
|
||||
createStepAutoFilled: false
|
||||
})
|
||||
).toEqual({ parent: '/Users/alice/orca/projects' })
|
||||
expect(
|
||||
getCreateProjectDefaultParentAutoFill({
|
||||
step: 'create',
|
||||
createParent: '/tmp/project',
|
||||
activeRuntimeEnvironmentId: null,
|
||||
defaultParent: '/Users/alice/orca/projects',
|
||||
createStepAutoFilled: false
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
getCreateProjectDefaultParentAutoFill({
|
||||
step: 'create',
|
||||
createParent: '',
|
||||
activeRuntimeEnvironmentId: null,
|
||||
defaultParent: '/Users/alice/orca/projects',
|
||||
createStepAutoFilled: true
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not apply a local default while a runtime environment is active', () => {
|
||||
expect(
|
||||
getCreateProjectDefaultParentAutoFill({
|
||||
step: 'create',
|
||||
createParent: '',
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
defaultParent: '/Users/alice/orca/projects',
|
||||
createStepAutoFilled: false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('uses a short local summary only for the local default parent', () => {
|
||||
expect(
|
||||
formatCreateProjectParentSummary({
|
||||
parent: '/Users/alice/orca/projects',
|
||||
defaultParent: '/Users/alice/orca/projects'
|
||||
})
|
||||
).toBe('~/orca/projects')
|
||||
expect(
|
||||
formatCreateProjectParentSummary({
|
||||
parent: '',
|
||||
defaultParent: '',
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
})
|
||||
).toBe('server folder not selected')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
export type RepoKind = 'git' | 'folder'
|
||||
|
||||
export type GitAvailability = 'checking' | 'available' | 'unavailable' | 'unknown'
|
||||
|
||||
function pathSeparatorFor(pathValue: string): '/' | '\\' {
|
||||
return pathValue.includes('\\') ? '\\' : '/'
|
||||
}
|
||||
|
||||
function trimTrailingSeparators(pathValue: string): string {
|
||||
const trimmed = pathValue.replace(/[\\/]+$/, '')
|
||||
if (trimmed === '' && pathValue.startsWith('/')) {
|
||||
return '/'
|
||||
}
|
||||
if (/^[A-Za-z]:$/.test(trimmed)) {
|
||||
return `${trimmed}${pathSeparatorFor(pathValue)}`
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function joinCreateProjectPath(parentPath: string, childName: string): string {
|
||||
const parent = trimTrailingSeparators(parentPath.trim())
|
||||
const child = childName.trim().replace(/^[\\/]+/, '')
|
||||
if (!parent || !child) {
|
||||
return parent || child
|
||||
}
|
||||
const separator = pathSeparatorFor(parent)
|
||||
if (parent === '/' || /^[A-Za-z]:[\\/]$/.test(parent)) {
|
||||
return `${parent}${child}`
|
||||
}
|
||||
return `${parent}${separator}${child}`
|
||||
}
|
||||
|
||||
export function getDefaultCreateProjectParent(homeDir: string): string {
|
||||
const trimmedHomeDir = trimTrailingSeparators(homeDir.trim())
|
||||
if (!trimmedHomeDir) {
|
||||
return ''
|
||||
}
|
||||
return joinCreateProjectPath(joinCreateProjectPath(trimmedHomeDir, 'orca'), 'projects')
|
||||
}
|
||||
|
||||
export function getCreateProjectDefaultParentAutoFill({
|
||||
step,
|
||||
createParent,
|
||||
activeRuntimeEnvironmentId,
|
||||
defaultParent,
|
||||
createStepAutoFilled
|
||||
}: {
|
||||
step: string
|
||||
createParent: string
|
||||
activeRuntimeEnvironmentId: string | null | undefined
|
||||
defaultParent?: string
|
||||
createStepAutoFilled: boolean
|
||||
}): { parent: string } | null {
|
||||
if (step !== 'create' || createStepAutoFilled || createParent) {
|
||||
return null
|
||||
}
|
||||
if (activeRuntimeEnvironmentId?.trim()) {
|
||||
return null
|
||||
}
|
||||
const parent = defaultParent ?? ''
|
||||
if (!parent) {
|
||||
return null
|
||||
}
|
||||
return { parent }
|
||||
}
|
||||
|
||||
export function formatCreateProjectParentSummary({
|
||||
parent,
|
||||
defaultParent,
|
||||
runtimeEnvironmentId,
|
||||
missingLocationLabel = 'location not selected',
|
||||
missingServerLocationLabel = 'server folder not selected'
|
||||
}: {
|
||||
parent: string
|
||||
defaultParent: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
missingLocationLabel?: string
|
||||
missingServerLocationLabel?: string
|
||||
}): string {
|
||||
const trimmedParent = parent.trim()
|
||||
if (!trimmedParent) {
|
||||
return runtimeEnvironmentId ? missingServerLocationLabel : missingLocationLabel
|
||||
}
|
||||
if (defaultParent && trimmedParent === defaultParent && !runtimeEnvironmentId) {
|
||||
return '~/orca/projects'
|
||||
}
|
||||
return trimmedParent
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as ReactModule from 'react'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
stateValues: [] as unknown[],
|
||||
stateIndex: 0,
|
||||
refValues: [] as { current: unknown }[],
|
||||
refIndex: 0,
|
||||
browseRuntimeServerDirectory: vi.fn(),
|
||||
callRuntimeRpc: vi.fn(),
|
||||
isGitAvailable: vi.fn(),
|
||||
getDefaultCreateProjectParent: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ReactModule>()
|
||||
return {
|
||||
...actual,
|
||||
useCallback: <T extends (...args: never[]) => unknown>(fn: T) => fn,
|
||||
useRef: <T>(value: T) => {
|
||||
const index = mocks.refIndex++
|
||||
if (!(index in mocks.refValues)) {
|
||||
mocks.refValues[index] = { current: value }
|
||||
}
|
||||
return mocks.refValues[index] as { current: T }
|
||||
},
|
||||
useEffect: (effect: () => void | (() => void)) => {
|
||||
void effect()
|
||||
},
|
||||
useState: <T>(initial: T) => {
|
||||
const index = mocks.stateIndex++
|
||||
if (!(index in mocks.stateValues)) {
|
||||
mocks.stateValues[index] = initial
|
||||
}
|
||||
const setter = (value: T) => {
|
||||
mocks.stateValues[index] = value
|
||||
}
|
||||
return [mocks.stateValues[index] as T, setter]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/runtime/runtime-server-directory-browser', () => ({
|
||||
browseRuntimeServerDirectory: mocks.browseRuntimeServerDirectory
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: mocks.callRuntimeRpc
|
||||
}))
|
||||
|
||||
import { useCreateProjectDefaults } from './useCreateProjectDefaults'
|
||||
|
||||
// State order inside the hook: [defaultParent, gitAvailability, runtimeParentStatus].
|
||||
const DEFAULT_PARENT_STATE = 0
|
||||
const GIT_AVAILABILITY_STATE = 1
|
||||
const RUNTIME_PARENT_STATUS_STATE = 2
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function useHarness(overrides: Partial<Parameters<typeof useCreateProjectDefaults>[0]> = {}) {
|
||||
mocks.stateIndex = 0
|
||||
mocks.refIndex = 0
|
||||
const setCreateParent = vi.fn()
|
||||
const setCreateKind = vi.fn()
|
||||
const result = useCreateProjectDefaults({
|
||||
step: 'create',
|
||||
activeRuntimeEnvironmentId: null,
|
||||
createParent: '',
|
||||
setCreateParent,
|
||||
setCreateKind,
|
||||
...overrides
|
||||
})
|
||||
return { result, setCreateParent, setCreateKind }
|
||||
}
|
||||
|
||||
describe('useCreateProjectDefaults', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.stateValues = []
|
||||
mocks.stateIndex = 0
|
||||
mocks.refValues = []
|
||||
mocks.refIndex = 0
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
repos: {
|
||||
isGitAvailable: mocks.isGitAvailable,
|
||||
getDefaultCreateProjectParent: mocks.getDefaultCreateProjectParent
|
||||
}
|
||||
}
|
||||
})
|
||||
mocks.getDefaultCreateProjectParent.mockResolvedValue('/Users/alice/orca/projects')
|
||||
})
|
||||
|
||||
it('auto-fills the local default parent and defaults to git when available', async () => {
|
||||
mocks.isGitAvailable.mockResolvedValue(true)
|
||||
|
||||
const { setCreateParent, setCreateKind } = useHarness()
|
||||
await flushAsync()
|
||||
|
||||
expect(setCreateParent).toHaveBeenCalledWith('/Users/alice/orca/projects')
|
||||
expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/Users/alice/orca/projects')
|
||||
expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('available')
|
||||
expect(setCreateKind).toHaveBeenCalledWith('git')
|
||||
expect(mocks.getDefaultCreateProjectParent).toHaveBeenCalled()
|
||||
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('auto-fills the local home default regardless of workspace directory settings', async () => {
|
||||
mocks.isGitAvailable.mockResolvedValue(true)
|
||||
|
||||
const { setCreateParent } = useHarness()
|
||||
await flushAsync()
|
||||
|
||||
expect(mocks.getDefaultCreateProjectParent).toHaveBeenCalled()
|
||||
expect(setCreateParent).toHaveBeenCalledWith('/Users/alice/orca/projects')
|
||||
expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/Users/alice/orca/projects')
|
||||
})
|
||||
|
||||
it('keeps the local default marker after the auto-filled parent rerenders the hook', async () => {
|
||||
mocks.isGitAvailable.mockResolvedValue(true)
|
||||
|
||||
useHarness()
|
||||
await flushAsync()
|
||||
useHarness({ createParent: '/Users/alice/orca/projects' })
|
||||
|
||||
expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/Users/alice/orca/projects')
|
||||
})
|
||||
|
||||
it('defaults to folder with a visible fallback when Git is unavailable', async () => {
|
||||
mocks.isGitAvailable.mockResolvedValue(false)
|
||||
|
||||
const { setCreateKind } = useHarness()
|
||||
await flushAsync()
|
||||
|
||||
expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unavailable')
|
||||
expect(setCreateKind).toHaveBeenCalledWith('folder')
|
||||
})
|
||||
|
||||
it('reports unknown availability and keeps the kind when the Git probe fails', async () => {
|
||||
mocks.isGitAvailable.mockRejectedValue(new Error('probe failed'))
|
||||
|
||||
const { setCreateKind } = useHarness()
|
||||
await flushAsync()
|
||||
|
||||
expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unknown')
|
||||
expect(setCreateKind).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not overwrite a parent the user already chose', async () => {
|
||||
mocks.isGitAvailable.mockResolvedValue(true)
|
||||
|
||||
const { setCreateParent } = useHarness({ createParent: '/tmp/custom' })
|
||||
await flushAsync()
|
||||
|
||||
expect(setCreateParent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves the runtime default parent from the host home directory', async () => {
|
||||
mocks.browseRuntimeServerDirectory.mockResolvedValue({ resolvedPath: '/home/alice' })
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ available: true })
|
||||
|
||||
const { setCreateParent, setCreateKind } = useHarness({ activeRuntimeEnvironmentId: 'env-1' })
|
||||
await flushAsync()
|
||||
|
||||
expect(mocks.browseRuntimeServerDirectory).toHaveBeenCalledWith('env-1', '~')
|
||||
expect(setCreateParent).toHaveBeenCalledWith('/home/alice/orca/projects')
|
||||
expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/home/alice/orca/projects')
|
||||
expect(mocks.stateValues[RUNTIME_PARENT_STATUS_STATE]).toBe('idle')
|
||||
// Why: runtime Git availability must be probed on the host, not the client.
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'env-1' },
|
||||
'repo.gitAvailable',
|
||||
undefined,
|
||||
{ timeoutMs: 3000 }
|
||||
)
|
||||
expect(mocks.isGitAvailable).not.toHaveBeenCalled()
|
||||
expect(setCreateKind).toHaveBeenCalledWith('git')
|
||||
})
|
||||
|
||||
it('replaces an untouched local default when switching to a runtime target', async () => {
|
||||
mocks.isGitAvailable.mockResolvedValue(true)
|
||||
mocks.browseRuntimeServerDirectory.mockResolvedValue({ resolvedPath: '/home/alice' })
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ available: true })
|
||||
|
||||
const local = useHarness()
|
||||
await flushAsync()
|
||||
expect(local.setCreateParent).toHaveBeenCalledWith('/Users/alice/orca/projects')
|
||||
|
||||
const runtime = useHarness({
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
createParent: '/Users/alice/orca/projects'
|
||||
})
|
||||
|
||||
expect(runtime.result.createParentDefaultPending).toBe(true)
|
||||
expect(runtime.setCreateParent).toHaveBeenCalledWith('')
|
||||
expect(mocks.stateValues[RUNTIME_PARENT_STATUS_STATE]).toBe('checking')
|
||||
expect(mocks.browseRuntimeServerDirectory).not.toHaveBeenCalled()
|
||||
|
||||
const resolvedRuntime = useHarness({
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
createParent: ''
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
expect(mocks.browseRuntimeServerDirectory).toHaveBeenCalledWith('env-1', '~')
|
||||
expect(resolvedRuntime.setCreateParent).toHaveBeenCalledWith('/home/alice/orca/projects')
|
||||
expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/home/alice/orca/projects')
|
||||
expect(resolvedRuntime.result.createParentDefaultPending).toBe(false)
|
||||
})
|
||||
|
||||
it('does not replace a touched parent when switching to a runtime target', async () => {
|
||||
mocks.isGitAvailable.mockResolvedValue(true)
|
||||
mocks.browseRuntimeServerDirectory.mockResolvedValue({ resolvedPath: '/home/alice' })
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ available: true })
|
||||
|
||||
const local = useHarness({ createParent: '/Users/alice/orca/projects' })
|
||||
local.result.markCreateParentTouched('/Users/alice/orca/projects/pr5115-target-switch')
|
||||
|
||||
const runtime = useHarness({
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
createParent: '/Users/alice/orca/projects/pr5115-target-switch'
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
expect(runtime.result.createParentDefaultPending).toBe(true)
|
||||
expect(mocks.browseRuntimeServerDirectory).not.toHaveBeenCalled()
|
||||
expect(runtime.setCreateParent).not.toHaveBeenCalled()
|
||||
|
||||
runtime.result.markCreateParentTouched('/home/alice/projects')
|
||||
const runtimeEdited = useHarness({
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
createParent: '/home/alice/projects'
|
||||
})
|
||||
|
||||
expect(runtimeEdited.result.createParentDefaultPending).toBe(false)
|
||||
})
|
||||
|
||||
it('marks the runtime parent lookup failed without filling a parent', async () => {
|
||||
mocks.browseRuntimeServerDirectory.mockRejectedValue(new Error('disconnected'))
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ available: true })
|
||||
|
||||
const { setCreateParent } = useHarness({ activeRuntimeEnvironmentId: 'env-1' })
|
||||
await flushAsync()
|
||||
|
||||
expect(mocks.stateValues[RUNTIME_PARENT_STATUS_STATE]).toBe('failed')
|
||||
expect(setCreateParent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing outside the create step', async () => {
|
||||
const { setCreateParent, setCreateKind } = useHarness({ step: 'add' })
|
||||
await flushAsync()
|
||||
|
||||
expect(setCreateParent).not.toHaveBeenCalled()
|
||||
expect(setCreateKind).not.toHaveBeenCalled()
|
||||
expect(mocks.isGitAvailable).not.toHaveBeenCalled()
|
||||
expect(mocks.browseRuntimeServerDirectory).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,307 @@
|
||||
// Default-driven create-project state for AddRepoDialog: resolves the default
|
||||
// parent (local/runtime host home) and probes Git
|
||||
// availability, guarding against stale async results when the target changes.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { browseRuntimeServerDirectory } from '@/runtime/runtime-server-directory-browser'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import type { AddRepoDialogStep } from './add-repo-dialog-types'
|
||||
import {
|
||||
getDefaultCreateProjectParent,
|
||||
type GitAvailability,
|
||||
type RepoKind
|
||||
} from './create-project-defaults'
|
||||
|
||||
const LOCAL_GIT_AVAILABILITY_TIMEOUT_MS = 1500
|
||||
const RUNTIME_GIT_AVAILABILITY_TIMEOUT_MS = 3000
|
||||
|
||||
export type CreateRuntimeParentStatus = 'idle' | 'checking' | 'failed'
|
||||
|
||||
type AutoFilledCreateParent = {
|
||||
parent: string
|
||||
runtimeEnvironmentId: string | null
|
||||
}
|
||||
|
||||
type CreateParentProvenance = {
|
||||
parent: string
|
||||
runtimeEnvironmentId: string | null
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error('Timed out')), timeoutMs)
|
||||
promise.then(
|
||||
(value) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
resolve(value)
|
||||
},
|
||||
(error) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateProjectDefaults({
|
||||
step,
|
||||
activeRuntimeEnvironmentId,
|
||||
createParent,
|
||||
setCreateParent,
|
||||
setCreateKind
|
||||
}: {
|
||||
step: AddRepoDialogStep
|
||||
activeRuntimeEnvironmentId: string | null | undefined
|
||||
createParent: string
|
||||
setCreateParent: (value: string) => void
|
||||
setCreateKind: (kind: RepoKind) => void
|
||||
}): {
|
||||
createDefaultParent: string
|
||||
createGitAvailability: GitAvailability
|
||||
createRuntimeParentStatus: CreateRuntimeParentStatus
|
||||
createParentDefaultPending: boolean
|
||||
resetCreateDefaultState: () => void
|
||||
markCreateParentTouched: (value?: string) => void
|
||||
markCreateKindTouched: () => void
|
||||
} {
|
||||
const [createDefaultParent, setCreateDefaultParent] = useState('')
|
||||
const [createGitAvailability, setCreateGitAvailability] = useState<GitAvailability>('unknown')
|
||||
const [createRuntimeParentStatus, setCreateRuntimeParentStatus] =
|
||||
useState<CreateRuntimeParentStatus>('idle')
|
||||
const createStepAutoFilledRef = useRef(false)
|
||||
const autoFilledCreateParentRef = useRef<AutoFilledCreateParent | null>(null)
|
||||
const createParentProvenanceRef = useRef<CreateParentProvenance | null>(null)
|
||||
const createParentTouchedRef = useRef(false)
|
||||
const createKindTouchedRef = useRef(false)
|
||||
const createParentDefaultGenRef = useRef(0)
|
||||
const createGitProbeGenRef = useRef(0)
|
||||
const activeCreateParentRuntimeEnvironmentId = activeRuntimeEnvironmentId?.trim() || null
|
||||
|
||||
const canReplaceCreateParentDefault = useCallback((parent: string): boolean => {
|
||||
if (createParentTouchedRef.current) {
|
||||
return false
|
||||
}
|
||||
const trimmedParent = parent.trim()
|
||||
return !trimmedParent || autoFilledCreateParentRef.current?.parent === trimmedParent
|
||||
}, [])
|
||||
|
||||
const resetCreateDefaultState = useCallback(() => {
|
||||
createParentDefaultGenRef.current++
|
||||
createGitProbeGenRef.current++
|
||||
createStepAutoFilledRef.current = false
|
||||
autoFilledCreateParentRef.current = null
|
||||
createParentProvenanceRef.current = null
|
||||
createParentTouchedRef.current = false
|
||||
createKindTouchedRef.current = false
|
||||
setCreateDefaultParent('')
|
||||
setCreateGitAvailability('unknown')
|
||||
setCreateRuntimeParentStatus('idle')
|
||||
}, [])
|
||||
|
||||
// Why: a default must never clobber a parent or kind the user picked themselves.
|
||||
const markCreateParentTouched = useCallback(
|
||||
(value?: string) => {
|
||||
autoFilledCreateParentRef.current = null
|
||||
createParentProvenanceRef.current = {
|
||||
parent: (value ?? createParent).trim(),
|
||||
runtimeEnvironmentId: activeCreateParentRuntimeEnvironmentId
|
||||
}
|
||||
createParentTouchedRef.current = true
|
||||
},
|
||||
[activeCreateParentRuntimeEnvironmentId, createParent]
|
||||
)
|
||||
const markCreateKindTouched = useCallback(() => {
|
||||
createKindTouchedRef.current = true
|
||||
}, [])
|
||||
|
||||
const createParentDefaultPending =
|
||||
step === 'create' &&
|
||||
!createParentTouchedRef.current &&
|
||||
Boolean(createParent.trim()) &&
|
||||
autoFilledCreateParentRef.current?.parent === createParent.trim() &&
|
||||
autoFilledCreateParentRef.current.runtimeEnvironmentId !==
|
||||
activeCreateParentRuntimeEnvironmentId
|
||||
const createParentTargetPending =
|
||||
step === 'create' &&
|
||||
Boolean(createParent.trim()) &&
|
||||
createParentProvenanceRef.current?.parent === createParent.trim() &&
|
||||
createParentProvenanceRef.current.runtimeEnvironmentId !==
|
||||
activeCreateParentRuntimeEnvironmentId
|
||||
const createParentPending = createParentDefaultPending || createParentTargetPending
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'create') {
|
||||
return
|
||||
}
|
||||
if (activeCreateParentRuntimeEnvironmentId) {
|
||||
return
|
||||
}
|
||||
// Why: invalidate any in-flight runtime parent probe once local mode owns the default.
|
||||
const gen = ++createParentDefaultGenRef.current
|
||||
if (!canReplaceCreateParentDefault(createParent)) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
createParent.trim() &&
|
||||
autoFilledCreateParentRef.current?.runtimeEnvironmentId !== null &&
|
||||
autoFilledCreateParentRef.current?.parent === createParent.trim()
|
||||
) {
|
||||
setCreateDefaultParent('')
|
||||
setCreateParent('')
|
||||
return
|
||||
}
|
||||
if (
|
||||
autoFilledCreateParentRef.current?.runtimeEnvironmentId === null &&
|
||||
autoFilledCreateParentRef.current.parent === createParent.trim()
|
||||
) {
|
||||
return
|
||||
}
|
||||
setCreateDefaultParent('')
|
||||
void window.api.repos
|
||||
.getDefaultCreateProjectParent()
|
||||
.then((parent) => {
|
||||
if (
|
||||
gen !== createParentDefaultGenRef.current ||
|
||||
!canReplaceCreateParentDefault(createParent) ||
|
||||
!parent
|
||||
) {
|
||||
return
|
||||
}
|
||||
setCreateDefaultParent(parent)
|
||||
createStepAutoFilledRef.current = true
|
||||
autoFilledCreateParentRef.current = { parent, runtimeEnvironmentId: null }
|
||||
createParentProvenanceRef.current = { parent, runtimeEnvironmentId: null }
|
||||
setCreateParent(parent)
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the field empty if the local host cannot provide a submit-ready default.
|
||||
})
|
||||
}, [
|
||||
activeRuntimeEnvironmentId,
|
||||
activeCreateParentRuntimeEnvironmentId,
|
||||
canReplaceCreateParentDefault,
|
||||
createParent,
|
||||
setCreateParent,
|
||||
step
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'create') {
|
||||
return
|
||||
}
|
||||
const runtimeEnvironmentId = activeCreateParentRuntimeEnvironmentId
|
||||
if (!runtimeEnvironmentId) {
|
||||
setCreateRuntimeParentStatus('idle')
|
||||
return
|
||||
}
|
||||
if (!canReplaceCreateParentDefault(createParent)) {
|
||||
setCreateRuntimeParentStatus('idle')
|
||||
return
|
||||
}
|
||||
if (
|
||||
createParent.trim() &&
|
||||
autoFilledCreateParentRef.current?.runtimeEnvironmentId !== runtimeEnvironmentId &&
|
||||
autoFilledCreateParentRef.current?.parent === createParent.trim()
|
||||
) {
|
||||
setCreateDefaultParent('')
|
||||
setCreateRuntimeParentStatus('checking')
|
||||
setCreateParent('')
|
||||
return
|
||||
}
|
||||
if (
|
||||
autoFilledCreateParentRef.current?.runtimeEnvironmentId === runtimeEnvironmentId &&
|
||||
autoFilledCreateParentRef.current.parent === createParent.trim()
|
||||
) {
|
||||
setCreateRuntimeParentStatus('idle')
|
||||
return
|
||||
}
|
||||
setCreateDefaultParent('')
|
||||
|
||||
const gen = ++createParentDefaultGenRef.current
|
||||
setCreateRuntimeParentStatus('checking')
|
||||
void withTimeout(
|
||||
browseRuntimeServerDirectory(runtimeEnvironmentId, '~'),
|
||||
RUNTIME_GIT_AVAILABILITY_TIMEOUT_MS
|
||||
)
|
||||
.then((result) => {
|
||||
if (
|
||||
gen !== createParentDefaultGenRef.current ||
|
||||
!canReplaceCreateParentDefault(createParent)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const parent = getDefaultCreateProjectParent(result.resolvedPath)
|
||||
createStepAutoFilledRef.current = true
|
||||
autoFilledCreateParentRef.current = { parent, runtimeEnvironmentId }
|
||||
createParentProvenanceRef.current = { parent, runtimeEnvironmentId }
|
||||
setCreateDefaultParent(parent)
|
||||
setCreateParent(parent)
|
||||
setCreateRuntimeParentStatus('idle')
|
||||
})
|
||||
.catch(() => {
|
||||
if (gen !== createParentDefaultGenRef.current) {
|
||||
return
|
||||
}
|
||||
setCreateRuntimeParentStatus('failed')
|
||||
})
|
||||
}, [
|
||||
activeRuntimeEnvironmentId,
|
||||
activeCreateParentRuntimeEnvironmentId,
|
||||
canReplaceCreateParentDefault,
|
||||
createParent,
|
||||
setCreateParent,
|
||||
step
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'create') {
|
||||
return
|
||||
}
|
||||
const runtimeEnvironmentId = activeRuntimeEnvironmentId?.trim()
|
||||
const gen = ++createGitProbeGenRef.current
|
||||
setCreateGitAvailability('checking')
|
||||
const probe = runtimeEnvironmentId
|
||||
? callRuntimeRpc<{ available: boolean }>(
|
||||
{ kind: 'environment', environmentId: runtimeEnvironmentId },
|
||||
'repo.gitAvailable',
|
||||
undefined,
|
||||
{ timeoutMs: RUNTIME_GIT_AVAILABILITY_TIMEOUT_MS }
|
||||
).then((result) => result.available)
|
||||
: window.api.repos.isGitAvailable()
|
||||
const timeoutMs = runtimeEnvironmentId
|
||||
? RUNTIME_GIT_AVAILABILITY_TIMEOUT_MS
|
||||
: LOCAL_GIT_AVAILABILITY_TIMEOUT_MS
|
||||
|
||||
void withTimeout(probe, timeoutMs)
|
||||
.then((available) => {
|
||||
if (gen !== createGitProbeGenRef.current) {
|
||||
return
|
||||
}
|
||||
setCreateGitAvailability(available ? 'available' : 'unavailable')
|
||||
if (createKindTouchedRef.current) {
|
||||
return
|
||||
}
|
||||
setCreateKind(available ? 'git' : 'folder')
|
||||
})
|
||||
.catch(() => {
|
||||
if (gen !== createGitProbeGenRef.current) {
|
||||
return
|
||||
}
|
||||
setCreateGitAvailability('unknown')
|
||||
})
|
||||
}, [activeRuntimeEnvironmentId, setCreateKind, step])
|
||||
|
||||
return {
|
||||
createDefaultParent,
|
||||
createGitAvailability,
|
||||
createRuntimeParentStatus,
|
||||
createParentDefaultPending: createParentPending,
|
||||
resetCreateDefaultState,
|
||||
markCreateParentTouched,
|
||||
markCreateKindTouched
|
||||
}
|
||||
}
|
||||
+26
-3
@@ -95,6 +95,7 @@ describe('useCreateRepo default-checkout handoff', () => {
|
||||
mocks.storeState.worktreesByRepo = {}
|
||||
mocks.createRepo.mockReset()
|
||||
mocks.createRemoteRepo.mockReset()
|
||||
mocks.storeState.settings.activeRuntimeEnvironmentId = null
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
repos: {
|
||||
@@ -110,7 +111,7 @@ describe('useCreateRepo default-checkout handoff', () => {
|
||||
const repo = makeRepo()
|
||||
mocks.createRepo.mockResolvedValue({ repo })
|
||||
mocks.fetchWorktrees.mockResolvedValue(true)
|
||||
const { useCreateRepo } = await import('./AddRepoCreateStep')
|
||||
const { useCreateRepo } = await import('./useCreateRepo')
|
||||
|
||||
const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady)
|
||||
await result.handleCreate()
|
||||
@@ -126,11 +127,33 @@ describe('useCreateRepo default-checkout handoff', () => {
|
||||
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id)
|
||||
})
|
||||
|
||||
it('returns the selected parent directory after the local picker applies it', async () => {
|
||||
const pickedDir = '/Users/alice/custom-projects'
|
||||
vi.mocked(window.api.repos.pickDirectory).mockResolvedValue(pickedDir)
|
||||
const { useCreateRepo } = await import('./useCreateRepo')
|
||||
|
||||
const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady)
|
||||
await expect(result.handlePickParent()).resolves.toBe(pickedDir)
|
||||
|
||||
expect(mocks.stateSetters[1]).toHaveBeenCalledWith(pickedDir)
|
||||
})
|
||||
|
||||
it('does not return a parent path when the runtime target blocks the local picker', async () => {
|
||||
mocks.storeState.settings.activeRuntimeEnvironmentId = 'env-1'
|
||||
const { useCreateRepo } = await import('./useCreateRepo')
|
||||
|
||||
const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady)
|
||||
await expect(result.handlePickParent()).resolves.toBeNull()
|
||||
|
||||
expect(window.api.repos.pickDirectory).not.toHaveBeenCalled()
|
||||
expect(mocks.stateSetters[1]).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues to completion when refresh is not authoritative after create', async () => {
|
||||
const repo = makeRepo()
|
||||
mocks.createRepo.mockResolvedValue({ repo })
|
||||
mocks.fetchWorktrees.mockResolvedValue(false)
|
||||
const { useCreateRepo } = await import('./AddRepoCreateStep')
|
||||
const { useCreateRepo } = await import('./useCreateRepo')
|
||||
|
||||
const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady)
|
||||
await result.handleCreate()
|
||||
@@ -154,7 +177,7 @@ describe('useCreateRepo default-checkout handoff', () => {
|
||||
mocks.storeState.worktreesByRepo = { [repoId]: [worktree] }
|
||||
return true
|
||||
})
|
||||
const { useCreateRepo } = await import('./AddRepoCreateStep')
|
||||
const { useCreateRepo } = await import('./useCreateRepo')
|
||||
|
||||
const result = useCreateRepo(mocks.fetchWorktrees, closeModal, mocks.onGitRepoReady)
|
||||
await result.handleCreate()
|
||||
@@ -0,0 +1,240 @@
|
||||
// Create-project flow hook for AddRepoDialog (orca#763), split from
|
||||
// AddRepoCreateStep so the create-state machine stays scoped and testable.
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { RepoKind } from './create-project-defaults'
|
||||
|
||||
export function useCreateRepo(
|
||||
fetchWorktrees: (
|
||||
repoId: string,
|
||||
options?: { requireAuthoritative?: boolean }
|
||||
) => Promise<boolean>,
|
||||
closeModal: () => void,
|
||||
onGitRepoReady?: (repoId: string) => void | Promise<void>,
|
||||
options: { hostId?: string | null; sshTargetId?: string | null } = {}
|
||||
) {
|
||||
const [createName, setCreateName] = useState('')
|
||||
const [createParent, setCreateParent] = useState('')
|
||||
const [createKind, setCreateKind] = useState<RepoKind>('git')
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const mountedRef = useMountedRef()
|
||||
const hostToken = options.hostId ?? options.sshTargetId ?? ''
|
||||
const hostTokenRef = useRef(hostToken)
|
||||
hostTokenRef.current = hostToken
|
||||
|
||||
// Why: monotonic ID so stale create callbacks can detect they were superseded
|
||||
// when the user clicks Back or closes the dialog mid-create. Mirrors the
|
||||
// cloneGenRef pattern in AddRepoDialog.
|
||||
const createGenRef = useRef(0)
|
||||
|
||||
const resetCreateState = useCallback(() => {
|
||||
createGenRef.current++
|
||||
setCreateName('')
|
||||
setCreateParent('')
|
||||
setCreateKind('git')
|
||||
setCreateError(null)
|
||||
setIsCreating(false)
|
||||
}, [])
|
||||
|
||||
const handlePickParent = useCallback(async (): Promise<string | null> => {
|
||||
if (options.sshTargetId) {
|
||||
// Why: the native picker can only browse the client machine. SSH create
|
||||
// uses a host path typed by the user until remote folder picking exists.
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.ssh_parent_manual',
|
||||
'Enter an SSH parent path.'
|
||||
)
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) {
|
||||
// Why: the native folder picker returns a client-local path. Runtime
|
||||
// project creation needs an explicit server parent path.
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.875dda0995',
|
||||
'Enter a server parent path.'
|
||||
)
|
||||
)
|
||||
return null
|
||||
}
|
||||
const gen = createGenRef.current
|
||||
const dir = await window.api.repos.pickDirectory()
|
||||
if (dir && gen === createGenRef.current && mountedRef.current) {
|
||||
setCreateParent(dir)
|
||||
setCreateError(null)
|
||||
return dir
|
||||
}
|
||||
return null
|
||||
}, [mountedRef, options.sshTargetId])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
const name = createName.trim()
|
||||
const parentPath = createParent.trim()
|
||||
if (!name || !parentPath) {
|
||||
return
|
||||
}
|
||||
const requestHostToken = hostTokenRef.current
|
||||
const gen = ++createGenRef.current
|
||||
setIsCreating(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(useAppStore.getState().settings)
|
||||
const result = options.sshTargetId
|
||||
? await window.api.repos.createRemote({
|
||||
connectionId: options.sshTargetId,
|
||||
parentPath,
|
||||
name,
|
||||
kind: createKind
|
||||
})
|
||||
: target.kind === 'environment'
|
||||
? await callRuntimeRpc<{ repo: Repo } | { error: string }>(
|
||||
target,
|
||||
'repo.create',
|
||||
{
|
||||
parentPath,
|
||||
name,
|
||||
kind: createKind
|
||||
},
|
||||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
: await window.api.repos.create({
|
||||
parentPath,
|
||||
name,
|
||||
kind: createKind
|
||||
})
|
||||
// Why: if the user closed the dialog or clicked Back mid-create,
|
||||
// createGenRef was bumped by resetCreateState. Ignore stale results.
|
||||
if (
|
||||
gen !== createGenRef.current ||
|
||||
requestHostToken !== hostTokenRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
if ('error' in result) {
|
||||
setCreateError(result.error)
|
||||
return
|
||||
}
|
||||
const repo = result.repo
|
||||
// Upsert into the store before the repos:changed event round-trips,
|
||||
// so the next step can find the repo immediately.
|
||||
const state = useAppStore.getState()
|
||||
const existingIdx = state.repos.findIndex((r) => r.id === repo.id)
|
||||
// Why: the IPC handler dedupes by path (see repos:create) and returns
|
||||
// the existing repo unchanged. If its ID is already in our store, the
|
||||
// handler took the dedup path — no new project was created, so don't
|
||||
// claim one was.
|
||||
const wasDeduped = existingIdx !== -1
|
||||
if (existingIdx === -1) {
|
||||
useAppStore.setState({ repos: [...state.repos, repo] })
|
||||
} else {
|
||||
const updated = [...state.repos]
|
||||
updated[existingIdx] = repo
|
||||
useAppStore.setState({ repos: updated })
|
||||
}
|
||||
if (wasDeduped) {
|
||||
toast.info(
|
||||
translate(
|
||||
'auto.components.sidebar.AddRepoCreateStep.2c12db1511',
|
||||
'Project already added'
|
||||
),
|
||||
{
|
||||
description: repo.displayName
|
||||
}
|
||||
)
|
||||
} else {
|
||||
toast.success(
|
||||
translate('auto.components.sidebar.AddRepoCreateStep.5e97f0c4b9', 'Project created'),
|
||||
{
|
||||
description: repo.displayName
|
||||
}
|
||||
)
|
||||
}
|
||||
if (isGitRepoKind(repo)) {
|
||||
// Why: Git repos use the shared default-checkout completion path.
|
||||
// Why: if refresh is temporarily non-authoritative, the shared opener
|
||||
// still reveals the project so the user is not left in a completed add flow.
|
||||
await fetchWorktrees(repo.id, { requireAuthoritative: true })
|
||||
if (
|
||||
gen !== createGenRef.current ||
|
||||
requestHostToken !== hostTokenRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
await onGitRepoReady?.(repo.id)
|
||||
} else {
|
||||
// Why: folder repos skip the Git default-checkout handoff, so activate the synthetic
|
||||
// root workspace before closing. Matches addNonGitFolder's behavior.
|
||||
await fetchWorktrees(repo.id)
|
||||
if (
|
||||
gen !== createGenRef.current ||
|
||||
requestHostToken !== hostTokenRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
const folderWorktree = useAppStore.getState().worktreesByRepo[repo.id]?.[0]
|
||||
if (folderWorktree) {
|
||||
activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto' })
|
||||
}
|
||||
await markOnboardingProjectAdded('addedFolder')
|
||||
closeModal()
|
||||
}
|
||||
} catch (err) {
|
||||
if (
|
||||
gen !== createGenRef.current ||
|
||||
requestHostToken !== hostTokenRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
setCreateError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
// Why: only clear the loading state if this invocation is still current;
|
||||
// a superseded create must not flip the flag back off for a new flow.
|
||||
if (
|
||||
gen === createGenRef.current &&
|
||||
requestHostToken === hostTokenRef.current &&
|
||||
mountedRef.current
|
||||
) {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
createName,
|
||||
createParent,
|
||||
createKind,
|
||||
fetchWorktrees,
|
||||
mountedRef,
|
||||
closeModal,
|
||||
onGitRepoReady,
|
||||
options.sshTargetId
|
||||
])
|
||||
|
||||
return {
|
||||
createName,
|
||||
createParent,
|
||||
createKind,
|
||||
createError,
|
||||
isCreating,
|
||||
setCreateName,
|
||||
setCreateParent,
|
||||
setCreateKind,
|
||||
setCreateError,
|
||||
resetCreateState,
|
||||
handlePickParent,
|
||||
handleCreate
|
||||
}
|
||||
}
|
||||
@@ -3038,8 +3038,14 @@
|
||||
"038729c107": "Folder",
|
||||
"11fd2a7db8": "Git repository",
|
||||
"180e9b5e48": "Project kind",
|
||||
"d877ece0d6": "Create a Git repository or a plain folder and open it in Orca.",
|
||||
"db9be12229": "Start a new project",
|
||||
"c7b9f94456": "Create a new project",
|
||||
"b100311784": "Name it and Orca will create a real project with sensible defaults.",
|
||||
"685b5eefe1": "{{kind}} in {{parent}}",
|
||||
"2a762f3b19": "Checking Git on this host...",
|
||||
"fe1e616c5b": "Git isn't installed, so a plain folder is the default.",
|
||||
"c234df77f7": "Choose or enter a server parent folder before creating.",
|
||||
"3a13f6e88b": "location not selected",
|
||||
"6ed14c0281": "server folder not selected",
|
||||
"5e97f0c4b9": "Project created",
|
||||
"2c12db1511": "Project already added",
|
||||
"875dda0995": "Enter a server parent path.",
|
||||
|
||||
@@ -3022,15 +3022,21 @@
|
||||
"0ae45b8238": "mi-proyecto",
|
||||
"a8149a3a5a": "Nombre",
|
||||
"038729c107": "Carpeta",
|
||||
"11fd2a7db8": "repositorio git",
|
||||
"11fd2a7db8": "Repositorio Git",
|
||||
"180e9b5e48": "Tipo de proyecto",
|
||||
"d877ece0d6": "Cree un repositorio Git o una carpeta simple y ábralo en Orca.",
|
||||
"db9be12229": "Iniciar un nuevo proyecto",
|
||||
"5e97f0c4b9": "Proyecto creado",
|
||||
"2c12db1511": "Proyecto ya agregado",
|
||||
"875dda0995": "Introduzca una ruta principal del servidor.",
|
||||
"45b7c26034": "Crear proyecto",
|
||||
"85085d74d2": "Creando…"
|
||||
"85085d74d2": "Creando…",
|
||||
"c7b9f94456": "Crear un proyecto nuevo",
|
||||
"b100311784": "Ponle un nombre y Orca creará un proyecto real con valores predeterminados sensatos.",
|
||||
"685b5eefe1": "{{kind}} en {{parent}}",
|
||||
"2a762f3b19": "Comprobando Git en este host...",
|
||||
"fe1e616c5b": "Git no está instalado, así que una carpeta simple será el valor predeterminado.",
|
||||
"c234df77f7": "Elige o introduce una carpeta principal del servidor antes de crear.",
|
||||
"3a13f6e88b": "ubicación no seleccionada",
|
||||
"6ed14c0281": "carpeta del servidor no seleccionada"
|
||||
},
|
||||
"AddRepoNestedImportStep": {
|
||||
"496f68cf8c": "Escaneo de repositorios. Haga clic para detener.",
|
||||
|
||||
@@ -3019,14 +3019,20 @@
|
||||
"038729c107": "フォルダ",
|
||||
"11fd2a7db8": "Gitリポジトリ",
|
||||
"180e9b5e48": "プロジェクトの種類",
|
||||
"d877ece0d6": "Git リポジトリまたはプレーン フォルダーを作成し、Orca で開きます。",
|
||||
"db9be12229": "新規プロジェクトを開始する",
|
||||
"5e97f0c4b9": "プロジェクトが作成されました",
|
||||
"2c12db1511": "プロジェクトはすでに追加されています",
|
||||
"875dda0995": "サーバーの親パスを入力します。",
|
||||
"ssh_parent_manual": "Enter an SSH parent path.",
|
||||
"45b7c26034": "プロジェクトの作成",
|
||||
"85085d74d2": "作成…"
|
||||
"85085d74d2": "作成中…",
|
||||
"c7b9f94456": "新しいプロジェクトを作成",
|
||||
"b100311784": "名前を付けると、Orca が適切な既定値で実際のプロジェクトを作成します。",
|
||||
"685b5eefe1": "{{parent}} の {{kind}}",
|
||||
"2a762f3b19": "このホストで Git を確認中...",
|
||||
"fe1e616c5b": "Git がインストールされていないため、通常のフォルダが既定になります。",
|
||||
"c234df77f7": "作成する前に、サーバーの親フォルダを選択または入力してください。",
|
||||
"3a13f6e88b": "場所が選択されていません",
|
||||
"6ed14c0281": "サーバーフォルダが選択されていません"
|
||||
},
|
||||
"AddRepoHostSelector": {
|
||||
"host": "Host",
|
||||
|
||||
@@ -3016,17 +3016,23 @@
|
||||
"AddRepoCreateStep": {
|
||||
"0ae45b8238": "내 프로젝트",
|
||||
"a8149a3a5a": "이름",
|
||||
"038729c107": "접는 사람",
|
||||
"11fd2a7db8": "힘내 저장소",
|
||||
"038729c107": "폴더",
|
||||
"11fd2a7db8": "Git 저장소",
|
||||
"180e9b5e48": "프로젝트 종류",
|
||||
"d877ece0d6": "Git 저장소 또는 일반 폴더를 생성하고 Orca에서 엽니다.",
|
||||
"db9be12229": "새 프로젝트 시작",
|
||||
"5e97f0c4b9": "프로젝트가 생성되었습니다.",
|
||||
"2c12db1511": "프로젝트가 이미 추가되었습니다.",
|
||||
"875dda0995": "서버 상위 경로를 입력하세요.",
|
||||
"ssh_parent_manual": "Enter an SSH parent path.",
|
||||
"45b7c26034": "프로젝트 생성",
|
||||
"85085d74d2": "만드는 중…"
|
||||
"85085d74d2": "만드는 중…",
|
||||
"c7b9f94456": "새 프로젝트 만들기",
|
||||
"b100311784": "이름을 지정하면 Orca가 적절한 기본값으로 실제 프로젝트를 만듭니다.",
|
||||
"685b5eefe1": "{{parent}}의 {{kind}}",
|
||||
"2a762f3b19": "이 호스트에서 Git 확인 중...",
|
||||
"fe1e616c5b": "Git이 설치되어 있지 않아 일반 폴더가 기본값입니다.",
|
||||
"c234df77f7": "만들기 전에 서버 상위 폴더를 선택하거나 입력하세요.",
|
||||
"3a13f6e88b": "위치가 선택되지 않음",
|
||||
"6ed14c0281": "서버 폴더가 선택되지 않음"
|
||||
},
|
||||
"AddRepoHostSelector": {
|
||||
"host": "Host",
|
||||
|
||||
@@ -3015,18 +3015,24 @@
|
||||
},
|
||||
"AddRepoCreateStep": {
|
||||
"0ae45b8238": "我的项目",
|
||||
"a8149a3a5a": "姓名",
|
||||
"a8149a3a5a": "名称",
|
||||
"038729c107": "文件夹",
|
||||
"11fd2a7db8": "Git 存储库",
|
||||
"180e9b5e48": "项目种类",
|
||||
"d877ece0d6": "创建 Git 存储库或普通文件夹并在 Orca 中打开它。",
|
||||
"db9be12229": "开始一个新项目",
|
||||
"180e9b5e48": "项目类型",
|
||||
"5e97f0c4b9": "项目已创建",
|
||||
"2c12db1511": "项目已添加",
|
||||
"875dda0995": "输入服务器父路径。",
|
||||
"ssh_parent_manual": "Enter an SSH parent path.",
|
||||
"45b7c26034": "创建项目",
|
||||
"85085d74d2": "创建中…"
|
||||
"85085d74d2": "创建中…",
|
||||
"c7b9f94456": "创建新项目",
|
||||
"b100311784": "为它命名,Orca 会使用合理的默认设置创建一个真实项目。",
|
||||
"685b5eefe1": "{{parent}} 中的 {{kind}}",
|
||||
"2a762f3b19": "正在检查此主机上的 Git...",
|
||||
"fe1e616c5b": "未安装 Git,因此默认创建普通文件夹。",
|
||||
"c234df77f7": "创建前请选择或输入服务器父文件夹。",
|
||||
"3a13f6e88b": "未选择位置",
|
||||
"6ed14c0281": "未选择服务器文件夹"
|
||||
},
|
||||
"AddRepoHostSelector": {
|
||||
"host": "Host",
|
||||
|
||||
@@ -997,6 +997,53 @@ describe('web UI preload API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('web repos preload API', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.doUnmock('./web-runtime-client')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/home/alice', '/home/alice/orca/projects'],
|
||||
['/', '/orca/projects'],
|
||||
['C:\\', 'C:\\orca\\projects']
|
||||
])(
|
||||
'resolves the default create-project parent from runtime host home %s',
|
||||
async (resolvedPath, expectedParent) => {
|
||||
const runtimeCalls: { method: string; params: unknown }[] = []
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
|
||||
runtimeCalls.push({ method, params })
|
||||
return Promise.resolve({
|
||||
id: method,
|
||||
ok: true,
|
||||
result: { resolvedPath, entries: [] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
await expect(globals.window.api.repos.getDefaultCreateProjectParent()).resolves.toBe(
|
||||
expectedParent
|
||||
)
|
||||
expect(runtimeCalls).toEqual([{ method: 'files.browseServerDir', params: { path: '~' } }])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('web worktree preload API', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
} from '../../../shared/feature-interactions'
|
||||
import { normalizeContextualTourIds, type ContextualTourId } from '../../../shared/contextual-tours'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults'
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1'
|
||||
const UI_STORAGE_KEY = 'orca.web.ui.v1'
|
||||
@@ -1023,6 +1024,14 @@ function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> {
|
||||
invalidateRuntimeWorktreeCaches()
|
||||
return callRuntimeResult('repo.create', { parentPath, name, kind })
|
||||
},
|
||||
isGitAvailable: async () =>
|
||||
(await callRuntimeResult<{ available: boolean }>('repo.gitAvailable')).available,
|
||||
getDefaultCreateProjectParent: async () => {
|
||||
const result = await callRuntimeResult<{ resolvedPath: string }>('files.browseServerDir', {
|
||||
path: '~'
|
||||
})
|
||||
return getDefaultCreateProjectParent(result.resolvedPath)
|
||||
},
|
||||
onCloneProgress: () => noopUnsubscribe,
|
||||
getGitUsername: () => Promise.resolve(''),
|
||||
getBaseRefDefault: async ({ repoId }) =>
|
||||
|
||||
Reference in New Issue
Block a user