diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index c5a330461d2..e0d716a15c6 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -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 Promise>() + const handlers = new Map unknown>() const mockWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } @@ -101,12 +107,19 @@ describe('repos:create', () => { } return handler(null, args) as Promise } + const callDefaultCreateProjectParent = (): Promise => { + 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) + 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, diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index b56603eef4b..d4576270b99 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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() 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 { + 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) { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index cfc33929428..fa5b46fbae3 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -3180,6 +3180,33 @@ describe('OrcaRuntimeService', () => { } }) + it('creates a missing runtime parent before creating the project directory', async () => { + const added: Record[] = [] + const createStore = { + ...store, + getRepos: () => [...added] as never, + addRepo: (repo: Record) => { + 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', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3c4fbea3fd6..f6c3d267db3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6650,6 +6650,15 @@ export class OrcaRuntimeService { return { resolvedPath: dirPath, entries: mapped } } + async isGitAvailable(): Promise { + 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 diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 1dc14f020d9..670951f8871 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 8bb44dea258..6a01fd9fae0 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -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, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 68994b1d045..5f75556e69e 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -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', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c92b87952d6..033eafe12e9 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -782,6 +782,8 @@ export type PreloadApi = { name: string kind: 'git' | 'folder' }) => Promise<{ repo: Repo } | { error: string }> + isGitAvailable: () => Promise + getDefaultCreateProjectParent: () => Promise onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void getGitUsername: (args: { repoId: string }) => Promise getBaseRefDefault: (args: { repoId: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 04287247c81..a05fdfe10db 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -460,6 +460,11 @@ const api = { create: (args) => ipcRenderer.invoke('repos:create', args), + isGitAvailable: (): Promise => ipcRenderer.invoke('repos:isGitAvailable'), + + getDefaultCreateProjectParent: (): Promise => + ipcRenderer.invoke('repos:getDefaultCreateProjectParent'), + remove: (args) => ipcRenderer.invoke('repos:remove', args), reorder: (args) => ipcRenderer.invoke('repos:reorder', args), diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx new file mode 100644 index 00000000000..5aa81139153 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx @@ -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( + + + + + + ) +} + +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') + 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=""') + }) +}) diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx index 62f7c7eed72..863b1121238 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx @@ -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, - closeModal: () => void, - onGitRepoReady?: (repoId: string) => void | Promise, - options: { hostId?: string | null; sshTargetId?: string | null } = {} -) { - const [createName, setCreateName] = useState('') - const [createParent, setCreateParent] = useState('') - const [createKind, setCreateKind] = useState('git') - const [createError, setCreateError] = useState(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(null) const radioFocusFrameRef = useRef(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({ <> - {translate('auto.components.sidebar.AddRepoCreateStep.db9be12229', 'Start a new project')} + {translate( + 'auto.components.sidebar.AddRepoCreateStep.c7b9f94456', + 'Create a new project' + )} {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.' )} @@ -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. */}
- {/* Kind toggle. Real radiogroup so screen readers announce it as a choice. */} -
- onKindChange('git')} - onArrowNav={cycleKind} - icon={} - title={translate( - 'auto.components.sidebar.AddRepoCreateStep.11fd2a7db8', - 'Git repository' - )} - caption="Initializes an empty Git repo" - /> - onKindChange('folder')} - onArrowNav={cycleKind} - icon={} - title={translate('auto.components.sidebar.AddRepoCreateStep.038729c107', 'Folder')} - caption="Create a new folder" - /> -
- {/* Name. Monospaced because it ends up as a directory name. */}
- {/* The local picker returns client paths; runtime servers browse host paths via RPC. */} - 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. */} +
+ + + {advancedOpen && ( +
+ {/* Real radiogroup so screen readers announce the segmented choice. */} +
+ + {translate( + 'auto.components.sidebar.AddRepoCreateStep.180e9b5e48', + 'Project kind' + )} + +
+ {(['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 ( + + ) + })} +
+ {showGitFallback && ( +

+ {translate( + 'auto.components.sidebar.AddRepoCreateStep.fe1e616c5b', + "Git isn't installed, so a plain folder is the default." + )} +

+ )} +
+ + {/* The local picker returns client paths; runtime servers browse host paths via RPC. */} + setBrowsingParent(true)} + /> + + {targetPathPreview && ( +

+ {targetPathPreview} +

+ )} +
+ )} +
{createError && (

diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 32609f76c6d..3c6ac47bb92 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -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} /> diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index 8d940d84563..9628e24b2fa 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -56,6 +56,10 @@ function renderStepContent(overrides: Partial): 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', () => { diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index 5a6b8a692e0..9da0f2de042 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -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} diff --git a/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx b/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx index 025045b7938..9b0ae00c3f9 100644 --- a/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx +++ b/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx @@ -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({

) : createParent ? (
- - - {createParent} diff --git a/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.ts b/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.ts index f1511e2bf5f..9b423ed5ca3 100644 --- a/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.ts +++ b/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.ts @@ -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 { diff --git a/src/renderer/src/components/sidebar/create-project-defaults.test.ts b/src/renderer/src/components/sidebar/create-project-defaults.test.ts new file mode 100644 index 00000000000..4691b605081 --- /dev/null +++ b/src/renderer/src/components/sidebar/create-project-defaults.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/sidebar/create-project-defaults.ts b/src/renderer/src/components/sidebar/create-project-defaults.ts new file mode 100644 index 00000000000..e5df269a967 --- /dev/null +++ b/src/renderer/src/components/sidebar/create-project-defaults.ts @@ -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 +} diff --git a/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts b/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts new file mode 100644 index 00000000000..3508458daa4 --- /dev/null +++ b/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts @@ -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() + return { + ...actual, + useCallback: unknown>(fn: T) => fn, + useRef: (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: (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 { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function useHarness(overrides: Partial[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() + }) +}) diff --git a/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts b/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts new file mode 100644 index 00000000000..9c94cba1629 --- /dev/null +++ b/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts @@ -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(promise: Promise, timeoutMs: number): Promise { + let timeout: ReturnType | null = null + return new Promise((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('unknown') + const [createRuntimeParentStatus, setCreateRuntimeParentStatus] = + useState('idle') + const createStepAutoFilledRef = useRef(false) + const autoFilledCreateParentRef = useRef(null) + const createParentProvenanceRef = useRef(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 + } +} diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.default-checkout.test.ts b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts similarity index 83% rename from src/renderer/src/components/sidebar/AddRepoCreateStep.default-checkout.test.ts rename to src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts index 1d94f17497f..bd455724903 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts @@ -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() diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts new file mode 100644 index 00000000000..89d5d80deae --- /dev/null +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -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, + closeModal: () => void, + onGitRepoReady?: (repoId: string) => void | Promise, + options: { hostId?: string | null; sshTargetId?: string | null } = {} +) { + const [createName, setCreateName] = useState('') + const [createParent, setCreateParent] = useState('') + const [createKind, setCreateKind] = useState('git') + const [createError, setCreateError] = useState(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 => { + 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 + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 13eabd36e02..c56d03813bd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 429750daced..cd442f403e2 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2eff318e980..57429146b9c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ed02d314133..b1051bfb580 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7daa12d802e..67a04e57f24 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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", diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index e512db817f7..643e1372317 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -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> { + 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() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 9ba1d828c2d..b35ad1214ec 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -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['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 }) =>