mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(sidebar): add "create new project" option to Add Repo dialog (#929)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai> Co-authored-by: Kiran Annadata <kiranannadata@macbookair.mynetworksettings.com> Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
Orca
Kiran Annadata
brennanb2025
parent
375a3e6351
commit
98b484eb9b
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Unit tests for repos:create (orca#763).
|
||||
*
|
||||
* Pins the invariants that matter here:
|
||||
* - Name validation catches empty/slash/./.. before any fs I/O.
|
||||
* - Empty pre-existing directories are accepted; non-empty ones are not.
|
||||
* - Only directories we create ourselves are removed on rollback — a folder
|
||||
* the user picked must survive a failure so they can retry.
|
||||
* - Git repos get an empty initial commit; without it, HEAD has no branch.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
removeHandlerMock,
|
||||
mockStore,
|
||||
mkdirMock,
|
||||
accessMock,
|
||||
readdirMock,
|
||||
rmMock,
|
||||
gitExecFileAsyncMock,
|
||||
rebuildAuthorizedRootsCacheMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
removeHandlerMock: vi.fn(),
|
||||
mockStore: {
|
||||
getRepos: vi.fn().mockReturnValue([]),
|
||||
addRepo: vi.fn(),
|
||||
removeRepo: vi.fn(),
|
||||
getRepo: vi.fn(),
|
||||
updateRepo: vi.fn()
|
||||
},
|
||||
mkdirMock: vi.fn(),
|
||||
accessMock: vi.fn(),
|
||||
readdirMock: vi.fn(),
|
||||
rmMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
rebuildAuthorizedRootsCacheMock: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
dialog: { showOpenDialog: vi.fn() },
|
||||
ipcMain: {
|
||||
handle: handleMock,
|
||||
removeHandler: removeHandlerMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
mkdir: mkdirMock,
|
||||
access: accessMock,
|
||||
readdir: readdirMock,
|
||||
rm: rmMock
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
gitSpawn: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
getGitUsername: vi.fn().mockReturnValue(''),
|
||||
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
|
||||
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
|
||||
searchBaseRefs: vi.fn().mockResolvedValue([])
|
||||
}))
|
||||
|
||||
vi.mock('./filesystem-auth', () => ({
|
||||
rebuildAuthorizedRootsCache: rebuildAuthorizedRootsCacheMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./ssh', () => ({
|
||||
getActiveMultiplexer: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerRepoHandlers } from './repos'
|
||||
|
||||
type CreateArgs = { parentPath: string; name: string; kind: 'git' | 'folder' }
|
||||
type CreateResult =
|
||||
| { repo: { id: string; path: string; kind: 'git' | 'folder' } }
|
||||
| { error: string }
|
||||
|
||||
describe('repos:create', () => {
|
||||
const handlers = new Map<string, (event: unknown, args: CreateArgs) => Promise<CreateResult>>()
|
||||
const mockWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn() }
|
||||
}
|
||||
|
||||
const callCreate = (args: CreateArgs): Promise<CreateResult> => {
|
||||
const handler = handlers.get('repos:create')
|
||||
if (!handler) {
|
||||
throw new Error('repos:create handler was never registered')
|
||||
}
|
||||
return handler(null, args)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => {
|
||||
handlers.set(channel, handler as (event: unknown, args: CreateArgs) => Promise<CreateResult>)
|
||||
})
|
||||
removeHandlerMock.mockReset()
|
||||
mockStore.getRepos.mockReset().mockReturnValue([])
|
||||
mockStore.addRepo.mockReset()
|
||||
mockWindow.webContents.send.mockReset()
|
||||
rebuildAuthorizedRootsCacheMock.mockReset().mockResolvedValue(undefined)
|
||||
|
||||
// Default baseline: target does NOT exist yet, mkdir succeeds, git OK.
|
||||
accessMock.mockReset().mockRejectedValue(new Error('ENOENT'))
|
||||
readdirMock.mockReset().mockResolvedValue([])
|
||||
mkdirMock.mockReset().mockResolvedValue(undefined)
|
||||
rmMock.mockReset().mockResolvedValue(undefined)
|
||||
gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
})
|
||||
|
||||
it('registers the repos:create handler', () => {
|
||||
expect(handlers.has('repos:create')).toBe(true)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
// ── input validation ──────────────────────────────────────────────
|
||||
|
||||
it('rejects empty names', async () => {
|
||||
const result = await callCreate({ parentPath: '/tmp', name: ' ', kind: 'git' })
|
||||
expect(result).toEqual({ error: 'Name cannot be empty' })
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects names containing a forward slash', async () => {
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'foo/bar', kind: 'git' })
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('slash') })
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects names containing a backslash', async () => {
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'foo\\bar', kind: 'git' })
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('slash') })
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects "." and ".." as names', async () => {
|
||||
for (const name of ['.', '..']) {
|
||||
mkdirMock.mockClear()
|
||||
const result = await callCreate({ parentPath: '/tmp', name, kind: 'git' })
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('slash') })
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects empty parent path', async () => {
|
||||
const result = await callCreate({ parentPath: ' ', name: 'project', kind: 'git' })
|
||||
expect(result).toEqual({ error: 'Parent directory is required' })
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── existing-directory handling ───────────────────────────────────
|
||||
|
||||
it('rejects a non-empty existing directory without calling mkdir', 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(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts an empty existing directory and does not call mkdir', async () => {
|
||||
accessMock.mockResolvedValueOnce(undefined) // exists
|
||||
readdirMock.mockResolvedValueOnce([])
|
||||
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'empty', kind: 'folder' })
|
||||
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: '/tmp/empty', kind: 'folder' })
|
||||
)
|
||||
expect(result).toHaveProperty('repo.kind', 'folder')
|
||||
})
|
||||
|
||||
it('creates a missing directory with mkdir', async () => {
|
||||
// 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 })
|
||||
})
|
||||
|
||||
// ── plain folder happy path ───────────────────────────────────────
|
||||
|
||||
it('creates a plain folder without running any git commands', async () => {
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'plain', kind: 'folder' })
|
||||
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/tmp/plain',
|
||||
displayName: 'plain',
|
||||
kind: 'folder'
|
||||
})
|
||||
)
|
||||
expect(result).toHaveProperty('repo.kind', 'folder')
|
||||
})
|
||||
|
||||
// ── git repo happy path ───────────────────────────────────────────
|
||||
|
||||
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(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['init'], { cwd: '/tmp/gitproj' })
|
||||
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['commit', '--allow-empty', '-m', 'Initial commit'],
|
||||
{ cwd: '/tmp/gitproj' }
|
||||
)
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/tmp/gitproj',
|
||||
displayName: 'gitproj',
|
||||
kind: 'git'
|
||||
})
|
||||
)
|
||||
expect(result).toHaveProperty('repo.kind', 'git')
|
||||
})
|
||||
|
||||
// ── rollback semantics ────────────────────────────────────────────
|
||||
|
||||
it('rolls back the directory it just created when git init fails', async () => {
|
||||
gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('git init blew up'))
|
||||
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'broken', kind: 'git' })
|
||||
|
||||
expect(rmMock).toHaveBeenCalledWith('/tmp/broken', { recursive: true, force: true })
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('Failed to initialize') })
|
||||
})
|
||||
|
||||
it('does NOT rm a pre-existing empty directory when git init fails', async () => {
|
||||
// Pretend the directory already existed (and is empty) — user pre-created it.
|
||||
accessMock.mockResolvedValueOnce(undefined)
|
||||
readdirMock.mockResolvedValueOnce([])
|
||||
gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('git init blew up'))
|
||||
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'preexisting', kind: 'git' })
|
||||
|
||||
expect(rmMock).not.toHaveBeenCalled()
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('Failed to initialize') })
|
||||
})
|
||||
|
||||
it('surfaces an "initialize"-flavored error when git init itself fails', async () => {
|
||||
// First git call (init) rejects — the commit step never runs.
|
||||
gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('init broke'))
|
||||
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'initfail', kind: 'git' })
|
||||
|
||||
expect(rmMock).toHaveBeenCalledWith('/tmp/initfail', { recursive: true, force: true })
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
// Loose match — handler distinguishes init vs commit failures, and we want
|
||||
// to tolerate small wording tweaks as long as it still mentions "initialize".
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('initialize') })
|
||||
})
|
||||
|
||||
it('rolls back directory when git commit fails (not just init)', async () => {
|
||||
// init resolves, commit rejects — the failure must still trigger rollback
|
||||
// and surface a commit-flavored error (distinct from the init-failure path).
|
||||
gitExecFileAsyncMock
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
.mockRejectedValueOnce(new Error('commit broke'))
|
||||
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'commitfail', kind: 'git' })
|
||||
|
||||
expect(rmMock).toHaveBeenCalledWith('/tmp/commitfail', { recursive: true, force: true })
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('commit') })
|
||||
})
|
||||
|
||||
it('strips only .git/ when commit fails in a pre-existing empty folder', async () => {
|
||||
// User pre-created an empty folder; git init succeeded, commit failed.
|
||||
// The folder itself must survive (user owns it) but the half-init'd
|
||||
// .git/ should be removed so the folder looks untouched.
|
||||
accessMock.mockResolvedValueOnce(undefined)
|
||||
readdirMock.mockResolvedValueOnce([])
|
||||
gitExecFileAsyncMock
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
.mockRejectedValueOnce(new Error('commit broke'))
|
||||
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'pre-existing', kind: 'git' })
|
||||
|
||||
expect(rmMock).toHaveBeenCalledWith('/tmp/pre-existing/.git', { recursive: true, force: true })
|
||||
expect(rmMock).not.toHaveBeenCalledWith('/tmp/pre-existing', { recursive: true, force: true })
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('commit') })
|
||||
})
|
||||
|
||||
// ── friendly messaging ────────────────────────────────────────────
|
||||
|
||||
it('surfaces a friendly message when git author identity is missing', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' }) // git init
|
||||
.mockRejectedValueOnce(
|
||||
new Error('Please tell me who you are. Run git config --global user.email ...')
|
||||
)
|
||||
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'authorless', kind: 'git' })
|
||||
|
||||
expect(rmMock).toHaveBeenCalledWith('/tmp/authorless', { recursive: true, force: true })
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({
|
||||
error: expect.stringContaining('Git author identity is not configured')
|
||||
})
|
||||
})
|
||||
|
||||
// ── renderer notification ─────────────────────────────────────────
|
||||
|
||||
it('notifies the renderer via repos:changed after a successful create', async () => {
|
||||
await callCreate({ parentPath: '/tmp', name: 'notified', kind: 'folder' })
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:changed')
|
||||
})
|
||||
|
||||
// ── authorized-roots cache refresh ────────────────────────────────
|
||||
|
||||
it('rebuilds the authorized-roots cache after a successful folder create', async () => {
|
||||
// Roots cache must be refreshed so the renderer can read the new path
|
||||
// without waiting for the next full reconciliation.
|
||||
await callCreate({ parentPath: '/tmp', name: 'rooted', kind: 'folder' })
|
||||
expect(rebuildAuthorizedRootsCacheMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does NOT rebuild the authorized-roots cache on a validation failure', async () => {
|
||||
const result = await callCreate({ parentPath: '/tmp', name: ' ', kind: 'git' })
|
||||
expect(result).toEqual({ error: 'Name cannot be empty' })
|
||||
expect(rebuildAuthorizedRootsCacheMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT rebuild the authorized-roots cache when dedup short-circuits', async () => {
|
||||
const existing = { id: 'abc', path: '/tmp/dupe2', displayName: 'dupe2', kind: 'git' }
|
||||
mockStore.getRepos.mockReturnValue([existing])
|
||||
|
||||
await callCreate({ parentPath: '/tmp', name: 'dupe2', kind: 'git' })
|
||||
|
||||
expect(rebuildAuthorizedRootsCacheMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── dedup-by-path ─────────────────────────────────────────────────
|
||||
|
||||
it('returns the existing repo when one already lives at the target path', async () => {
|
||||
const existing = { id: 'abc', path: '/tmp/dupe', displayName: 'dupe', kind: 'git' }
|
||||
mockStore.getRepos.mockReturnValue([existing])
|
||||
|
||||
const result = await callCreate({ parentPath: '/tmp', name: 'dupe', kind: 'git' })
|
||||
|
||||
expect(result).toEqual({ repo: existing })
|
||||
// Short-circuit before any fs or git work.
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+191
-3
@@ -10,9 +10,9 @@ import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { REPO_COLORS } from '../../shared/constants'
|
||||
import { rebuildAuthorizedRootsCache } from './filesystem-auth'
|
||||
import type { ChildProcess } from 'child_process'
|
||||
import { rm } from 'fs/promises'
|
||||
import { gitSpawn } from '../git/runner'
|
||||
import { join, basename } from 'path'
|
||||
import { access, mkdir, readdir, rm } from 'fs/promises'
|
||||
import { gitExecFileAsync, gitSpawn } from '../git/runner'
|
||||
import { basename, isAbsolute, join } from 'path'
|
||||
import {
|
||||
isGitRepo,
|
||||
getGitUsername,
|
||||
@@ -51,6 +51,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
ipcMain.removeHandler('repos:getBaseRefDefault')
|
||||
ipcMain.removeHandler('repos:searchBaseRefs')
|
||||
ipcMain.removeHandler('repos:addRemote')
|
||||
ipcMain.removeHandler('repos:create')
|
||||
ipcMain.removeHandler('sparsePresets:list')
|
||||
ipcMain.removeHandler('sparsePresets:save')
|
||||
ipcMain.removeHandler('sparsePresets:remove')
|
||||
@@ -198,6 +199,193 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
}
|
||||
)
|
||||
|
||||
// Creates a new repo or folder from scratch (orca#763). An empty initial
|
||||
// commit is required for git repos so HEAD has a branch ref — Orca's
|
||||
// worktree features all need one.
|
||||
ipcMain.handle(
|
||||
'repos:create',
|
||||
async (
|
||||
_event,
|
||||
args: { parentPath: string; name: string; kind: 'git' | 'folder' }
|
||||
): Promise<{ repo: Repo } | { error: string }> => {
|
||||
const name = args.name?.trim() ?? ''
|
||||
const parentPath = args.parentPath?.trim() ?? ''
|
||||
// Why: IPC input is untrusted — coerce to the narrow union so a bogus
|
||||
// string (e.g. "x") can't skip git init yet persist as kind: "x" in the
|
||||
// store. Mirrors the coercion in repos:add above.
|
||||
const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git'
|
||||
|
||||
if (!name) {
|
||||
return { error: 'Name cannot be empty' }
|
||||
}
|
||||
// Block slashes and ./.. so the name can't escape the chosen parent.
|
||||
// The UI already disables submit in these cases; this guards direct IPC use.
|
||||
if (/[\\/]/.test(name) || name === '.' || name === '..') {
|
||||
return { error: 'Name cannot contain slashes or be "." / ".."' }
|
||||
}
|
||||
if (!parentPath) {
|
||||
return { error: 'Parent directory is required' }
|
||||
}
|
||||
// Why: blocks CWD-relative paths from slipping through the IPC boundary;
|
||||
// the UI uses pickDirectory which returns absolute paths, this guards
|
||||
// direct IPC use (and keeps targetPath stable across process cwd changes).
|
||||
if (!isAbsolute(parentPath)) {
|
||||
return { error: 'Parent directory must be an absolute path' }
|
||||
}
|
||||
|
||||
const targetPath = join(parentPath, name)
|
||||
|
||||
// Dedup by path (same as repos:add) so a double-click on Create doesn't
|
||||
// produce two sidebar entries pointing at the same folder. This is the
|
||||
// first of three dedup checks; see the pre-addRepo check below for why
|
||||
// the race matters even after this one passes.
|
||||
const existing = store.getRepos().find((r) => r.path === targetPath)
|
||||
if (existing) {
|
||||
return { repo: existing }
|
||||
}
|
||||
|
||||
// Empty pre-existing directories are allowed (e.g. one the user made in
|
||||
// Finder first). Non-empty ones are rejected so we don't overwrite files.
|
||||
let createdDir = false
|
||||
let targetExists = false
|
||||
try {
|
||||
await access(targetPath)
|
||||
targetExists = true
|
||||
} catch (err) {
|
||||
// Why: only ENOENT means "the path is free to use". Other codes
|
||||
// (EACCES, ENOTDIR, EPERM, ELOOP, ...) mean something is in the way
|
||||
// that mkdir can't fix — surface a precise error instead of falling
|
||||
// through to mkdir and returning a misleading "Failed to create
|
||||
// directory" message.
|
||||
//
|
||||
// Why the message fallback: fs.promises.access always attaches a
|
||||
// NodeJS.ErrnoException code in production, but plain Error objects
|
||||
// thrown in tests / non-Node contexts won't — treat a message that
|
||||
// reads like ENOENT as one so we don't over-reject.
|
||||
const code =
|
||||
err && typeof err === 'object' && 'code' in err
|
||||
? (err as NodeJS.ErrnoException).code
|
||||
: undefined
|
||||
const looksLikeEnoent =
|
||||
code === 'ENOENT' ||
|
||||
(code === undefined && err instanceof Error && /ENOENT/.test(err.message))
|
||||
if (!looksLikeEnoent) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return { error: `Cannot access target path: ${message}` }
|
||||
}
|
||||
}
|
||||
|
||||
if (targetExists) {
|
||||
try {
|
||||
const entries = await readdir(targetPath)
|
||||
if (entries.length > 0) {
|
||||
return {
|
||||
error: `"${name}" already exists at this location and is not empty.`
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Why: access succeeded but readdir failed — the path exists but we
|
||||
// can't inspect it (e.g. it's a file, not a directory; or perms).
|
||||
// mkdir would definitely fail here too, so return a distinct error.
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return { error: `Failed to read directory: ${message}` }
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await mkdir(targetPath, { recursive: false })
|
||||
createdDir = true
|
||||
} catch (err) {
|
||||
// Why: EEXIST here means another concurrent repos:create for the
|
||||
// same path won the mkdir race. If they already added the repo to
|
||||
// the store, return that entry instead of a confusing error. This
|
||||
// is the second dedup check; see the pre-addRepo check below for
|
||||
// the full race explanation.
|
||||
const code =
|
||||
err && typeof err === 'object' && 'code' in err
|
||||
? (err as NodeJS.ErrnoException).code
|
||||
: undefined
|
||||
const isEexist = code === 'EEXIST' || (err instanceof Error && /EEXIST/.test(err.message))
|
||||
if (isEexist) {
|
||||
const raceWinner = store.getRepos().find((r) => r.path === targetPath)
|
||||
if (raceWinner) {
|
||||
return { repo: raceWinner }
|
||||
}
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return { error: `Failed to create directory: ${message}` }
|
||||
}
|
||||
}
|
||||
|
||||
if (repoKind === 'git') {
|
||||
// Why: track which git step is running so the catch can attribute the
|
||||
// failure correctly. The identity-hint regex is only meaningful during
|
||||
// commit — git init itself never produces "Please tell me who you are".
|
||||
let step: 'init' | 'commit' = 'init'
|
||||
try {
|
||||
await gitExecFileAsync(['init'], { cwd: targetPath })
|
||||
step = 'commit'
|
||||
await gitExecFileAsync(['commit', '--allow-empty', '-m', 'Initial commit'], {
|
||||
cwd: targetPath
|
||||
})
|
||||
} catch (err) {
|
||||
// Only remove the directory if we made it. A pre-existing folder the
|
||||
// user picked must survive so they can retry after fixing git config.
|
||||
// Why: if we didn't make the directory but `git init` created `.git/`
|
||||
// inside it, strip just `.git/` so the user's folder looks the way
|
||||
// they left it. Retrying works either way, but leaving a half-init'd
|
||||
// repo behind is confusing if they choose to skip the retry.
|
||||
if (createdDir) {
|
||||
await rm(targetPath, { recursive: true, force: true }).catch(() => {})
|
||||
} else if (step === 'commit') {
|
||||
await rm(join(targetPath, '.git'), { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (
|
||||
step === 'commit' &&
|
||||
/Please tell me who you are|user\.name|user\.email/i.test(message)
|
||||
) {
|
||||
return {
|
||||
error:
|
||||
'Git author identity is not configured. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"`, then try again.'
|
||||
}
|
||||
}
|
||||
const stepLabel =
|
||||
step === 'init'
|
||||
? 'Failed to initialize git repository'
|
||||
: 'Failed to create initial commit'
|
||||
return { error: `${stepLabel}: ${message}` }
|
||||
}
|
||||
}
|
||||
|
||||
// Why: ipcMain.handle doesn't serialize concurrent calls; re-running the
|
||||
// dedup lookup here closes the window between the first check and
|
||||
// addRepo. A second repos:create for the same path that raced past the
|
||||
// initial dedup now returns the entry the first call persisted.
|
||||
const raceWinner = store.getRepos().find((r) => r.path === targetPath)
|
||||
if (raceWinner) {
|
||||
// Why: do NOT rm even if this invocation created the directory — the
|
||||
// other invocation is using it. Leaking a freshly-made empty folder on
|
||||
// a rare race is strictly safer than deleting a directory the winning
|
||||
// call (and the user) now owns.
|
||||
return { repo: raceWinner }
|
||||
}
|
||||
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: targetPath,
|
||||
displayName: name,
|
||||
badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length],
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind
|
||||
}
|
||||
|
||||
store.addRepo(repo)
|
||||
await rebuildAuthorizedRootsCache(store)
|
||||
notifyReposChanged(mainWindow)
|
||||
return { repo }
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => {
|
||||
store.removeRepo(args.repoId)
|
||||
await rebuildAuthorizedRootsCache(store)
|
||||
|
||||
@@ -27,7 +27,10 @@ import { registerTelemetryHandlers } from './telemetry'
|
||||
function captureHandlers(): void {
|
||||
handlers.clear()
|
||||
for (const call of handleMock.mock.calls) {
|
||||
const [channel, handler] = call as [string, typeof handlers extends Map<string, infer V> ? V : never]
|
||||
const [channel, handler] = call as [
|
||||
string,
|
||||
typeof handlers extends Map<string, infer V> ? V : never
|
||||
]
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-27
@@ -25,34 +25,28 @@ import { setOptIn, track } from '../telemetry/client'
|
||||
import type { EventName, EventProps } from '../../shared/telemetry-events'
|
||||
|
||||
export function registerTelemetryHandlers(): void {
|
||||
ipcMain.handle(
|
||||
'telemetry:track',
|
||||
(_event, name: unknown, props: unknown): void => {
|
||||
// Strict input typing: non-string names are dropped at the boundary
|
||||
// before the validator even sees them. The validator would also drop
|
||||
// (unknown event name), but the main-side narrow keeps the attack
|
||||
// surface minimal — a flood of bogus payloads does not exercise the
|
||||
// Zod parser for no reason.
|
||||
if (typeof name !== 'string') {
|
||||
return
|
||||
}
|
||||
// `props` may legitimately be omitted; treat `undefined`/`null` as an
|
||||
// empty object before the validator. Anything else non-object (e.g.
|
||||
// a string, a number) is a boundary violation.
|
||||
if (props !== null && props !== undefined && typeof props !== 'object') {
|
||||
return
|
||||
}
|
||||
// The casts to `EventName` / `EventProps<EventName>` here are
|
||||
// pass-through only — this file does NOT pretend the renderer's
|
||||
// name/props are type-safe. The validator inside `track()` is the
|
||||
// single enforcement point at runtime; these casts only feed the
|
||||
// typed channel that the validator will re-check.
|
||||
track(
|
||||
name as EventName,
|
||||
(props ?? {}) as EventProps<EventName>
|
||||
)
|
||||
ipcMain.handle('telemetry:track', (_event, name: unknown, props: unknown): void => {
|
||||
// Strict input typing: non-string names are dropped at the boundary
|
||||
// before the validator even sees them. The validator would also drop
|
||||
// (unknown event name), but the main-side narrow keeps the attack
|
||||
// surface minimal — a flood of bogus payloads does not exercise the
|
||||
// Zod parser for no reason.
|
||||
if (typeof name !== 'string') {
|
||||
return
|
||||
}
|
||||
)
|
||||
// `props` may legitimately be omitted; treat `undefined`/`null` as an
|
||||
// empty object before the validator. Anything else non-object (e.g.
|
||||
// a string, a number) is a boundary violation.
|
||||
if (props !== null && props !== undefined && typeof props !== 'object') {
|
||||
return
|
||||
}
|
||||
// The casts to `EventName` / `EventProps<EventName>` here are
|
||||
// pass-through only — this file does NOT pretend the renderer's
|
||||
// name/props are type-safe. The validator inside `track()` is the
|
||||
// single enforcement point at runtime; these casts only feed the
|
||||
// typed channel that the validator will re-check.
|
||||
track(name as EventName, (props ?? {}) as EventProps<EventName>)
|
||||
})
|
||||
|
||||
ipcMain.handle('telemetry:setOptIn', (_event, optedIn: unknown): void => {
|
||||
// Strict input typing — renderer can pass anything over IPC.
|
||||
|
||||
@@ -59,7 +59,10 @@ function makeFakeStore(settings: GlobalSettings): Store {
|
||||
getSettings: vi.fn(() => settings),
|
||||
updateSettings: vi.fn((updates: Partial<GlobalSettings>) => {
|
||||
if (updates.telemetry) {
|
||||
settings.telemetry = { ...settings.telemetry, ...updates.telemetry } as typeof settings.telemetry
|
||||
settings.telemetry = {
|
||||
...settings.telemetry,
|
||||
...updates.telemetry
|
||||
} as typeof settings.telemetry
|
||||
}
|
||||
return settings
|
||||
})
|
||||
|
||||
@@ -86,11 +86,7 @@ let storeRef: Store | null = null
|
||||
// be bounded by `resolveConsent` + the validator.
|
||||
let testTransportEnabled = false
|
||||
|
||||
function buildCommonProps(
|
||||
installId: string,
|
||||
sid: string,
|
||||
channel: 'stable' | 'rc'
|
||||
): CommonProps {
|
||||
function buildCommonProps(installId: string, sid: string, channel: 'stable' | 'rc'): CommonProps {
|
||||
// `.max(64)` on every free-form string field in `commonPropsSchema` is the
|
||||
// upper bound; node's platform / arch / release strings are always well
|
||||
// under that in practice. We do not truncate here because the validator's
|
||||
|
||||
@@ -365,6 +365,12 @@ export type PreloadApi = {
|
||||
displayName?: string
|
||||
kind?: 'git' | 'folder'
|
||||
}) => Promise<{ repo: Repo } | { error: string }>
|
||||
// Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`.
|
||||
create: (args: {
|
||||
parentPath: string
|
||||
name: string
|
||||
kind: 'git' | 'folder'
|
||||
}) => Promise<{ repo: Repo } | { error: string }>
|
||||
onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void
|
||||
getGitUsername: (args: { repoId: string }) => Promise<string>
|
||||
getBaseRefDefault: (args: { repoId: string }) => Promise<BaseRefDefaultResult>
|
||||
|
||||
@@ -212,6 +212,12 @@ const api = {
|
||||
kind?: 'git' | 'folder'
|
||||
}): Promise<unknown> => ipcRenderer.invoke('repos:addRemote', args),
|
||||
|
||||
create: (args: {
|
||||
parentPath: string
|
||||
name: string
|
||||
kind: 'git' | 'folder'
|
||||
}): Promise<unknown> => ipcRenderer.invoke('repos:create', args),
|
||||
|
||||
remove: (args: { repoId: string }): Promise<void> => ipcRenderer.invoke('repos:remove', args),
|
||||
|
||||
update: (args: { repoId: string; updates: Record<string, unknown> }): Promise<unknown> =>
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Step for AddRepoDialog (orca#763).
|
||||
*
|
||||
* Split from AddRepoDialog and AddRepoSteps to keep both under the 400-line
|
||||
* oxlint limit, following the same pattern as useRemoteRepo.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Folder, GitBranch, Home, Pencil } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
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 { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
|
||||
type DialogStep = 'add' | 'clone' | 'remote' | 'create' | 'setup'
|
||||
type RepoKind = 'git' | 'folder'
|
||||
|
||||
export function useCreateRepo(
|
||||
fetchWorktrees: (repoId: string) => Promise<void>,
|
||||
setStep: (step: DialogStep) => void,
|
||||
setAddedRepo: (repo: Repo | null) => void,
|
||||
closeModal: () => void
|
||||
) {
|
||||
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)
|
||||
|
||||
// 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 () => {
|
||||
const dir = await window.api.repos.pickDirectory()
|
||||
if (dir) {
|
||||
setCreateParent(dir)
|
||||
setCreateError(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
const name = createName.trim()
|
||||
const parentPath = createParent.trim()
|
||||
if (!name || !parentPath) {
|
||||
return
|
||||
}
|
||||
const gen = ++createGenRef.current
|
||||
setIsCreating(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
const result = 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) {
|
||||
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('Project already added', {
|
||||
description: repo.displayName
|
||||
})
|
||||
} else {
|
||||
toast.success('Project created', {
|
||||
description: repo.displayName
|
||||
})
|
||||
}
|
||||
if (isGitRepoKind(repo)) {
|
||||
// Why: setAddedRepo only drives the git "setup" step; the folder
|
||||
// branch closes the dialog, which resets addedRepo to null anyway.
|
||||
setAddedRepo(repo)
|
||||
await fetchWorktrees(repo.id)
|
||||
if (gen !== createGenRef.current) {
|
||||
return
|
||||
}
|
||||
setStep('setup')
|
||||
} else {
|
||||
// Why: without activating the new folder, the dialog closes and users
|
||||
// see no change. Matches addNonGitFolder's behavior in the store slice.
|
||||
await fetchWorktrees(repo.id)
|
||||
if (gen !== createGenRef.current) {
|
||||
return
|
||||
}
|
||||
const folderWorktree = useAppStore.getState().worktreesByRepo[repo.id]?.[0]
|
||||
if (folderWorktree) {
|
||||
activateAndRevealWorktree(folderWorktree.id)
|
||||
}
|
||||
closeModal()
|
||||
}
|
||||
} catch (err) {
|
||||
if (gen !== createGenRef.current) {
|
||||
return
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setCreateError(message)
|
||||
} 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) {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
}, [createName, createParent, createKind, fetchWorktrees, setStep, setAddedRepo, closeModal])
|
||||
|
||||
return {
|
||||
createName,
|
||||
createParent,
|
||||
createKind,
|
||||
createError,
|
||||
isCreating,
|
||||
setCreateName,
|
||||
setCreateKind,
|
||||
setCreateError,
|
||||
resetCreateState,
|
||||
handlePickParent,
|
||||
handleCreate
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI helpers ───────────────────────────────────────────────────────
|
||||
|
||||
type KindCardProps = {
|
||||
kind: RepoKind
|
||||
selected: boolean
|
||||
disabled: boolean
|
||||
onSelect: () => void
|
||||
onArrowNav: () => void
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
caption: string
|
||||
}
|
||||
|
||||
function KindCard({
|
||||
kind,
|
||||
selected,
|
||||
disabled,
|
||||
onSelect,
|
||||
onArrowNav,
|
||||
icon,
|
||||
title,
|
||||
caption
|
||||
}: KindCardProps): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => {
|
||||
// Why: WAI-ARIA radiogroup spec expects all four arrow keys to move
|
||||
// selection. Left/Right handle the horizontal grid layout; Up/Down
|
||||
// are added so vertical nav (e.g. screen-reader users, future layout
|
||||
// changes) behaves the same.
|
||||
if (
|
||||
e.key === 'ArrowLeft' ||
|
||||
e.key === 'ArrowRight' ||
|
||||
e.key === 'ArrowUp' ||
|
||||
e.key === 'ArrowDown'
|
||||
) {
|
||||
e.preventDefault()
|
||||
onArrowNav()
|
||||
} else if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onSelect()
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
data-kind={kind}
|
||||
className={`group relative flex items-center gap-3 rounded-md border px-3.5 py-3.5 text-left text-xs transition-colors cursor-pointer outline-none ${
|
||||
selected ? 'border-foreground/30 bg-accent' : 'border-border hover:bg-accent/50'
|
||||
} focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60`}
|
||||
>
|
||||
{/* Icon chip gives the glyph enough weight to sit balanced next to the title block. */}
|
||||
<span
|
||||
className={`shrink-0 inline-flex items-center justify-center size-8 rounded-md border transition-colors ${
|
||||
selected
|
||||
? 'border-foreground/20 bg-background/60 text-foreground'
|
||||
: 'border-border/70 bg-background/30 text-muted-foreground group-hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[13px] font-medium leading-tight">{title}</span>
|
||||
<span className="block text-[11px] text-muted-foreground leading-snug mt-0.5">
|
||||
{caption}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type CreateStepProps = {
|
||||
createName: string
|
||||
createParent: string
|
||||
createKind: RepoKind
|
||||
createError: string | null
|
||||
isCreating: boolean
|
||||
onNameChange: (value: string) => void
|
||||
onKindChange: (kind: RepoKind) => void
|
||||
onPickParent: () => void
|
||||
onCreate: () => void
|
||||
}
|
||||
|
||||
export function CreateStep({
|
||||
createName,
|
||||
createParent,
|
||||
createKind,
|
||||
createError,
|
||||
isCreating,
|
||||
onNameChange,
|
||||
onKindChange,
|
||||
onPickParent,
|
||||
onCreate
|
||||
}: CreateStepProps): React.JSX.Element {
|
||||
const radioGroupRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Arrow keys cycle selection within the radiogroup (WAI-ARIA radio pattern).
|
||||
const cycleKind = useCallback(() => {
|
||||
const next = createKind === 'git' ? 'folder' : 'git'
|
||||
onKindChange(next)
|
||||
requestAnimationFrame(() => {
|
||||
const nextEl = radioGroupRef.current?.querySelector<HTMLButtonElement>(
|
||||
`[data-kind="${next}"]`
|
||||
)
|
||||
nextEl?.focus()
|
||||
})
|
||||
}, [createKind, onKindChange])
|
||||
|
||||
const trimmedName = createName.trim()
|
||||
const canSubmit = trimmedName.length > 0 && createParent.trim().length > 0 && !isCreating
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Start a new project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a Git repository or a plain folder and open it in Orca.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Why: DialogContent is a CSS grid; grid items default to min-width:auto
|
||||
(= content size), so a long path inside the Location row would blow out
|
||||
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={radioGroupRef}
|
||||
role="radiogroup"
|
||||
aria-label="Project kind"
|
||||
className="grid grid-cols-2 gap-2"
|
||||
>
|
||||
<KindCard
|
||||
kind="git"
|
||||
selected={createKind === 'git'}
|
||||
disabled={isCreating}
|
||||
onSelect={() => onKindChange('git')}
|
||||
onArrowNav={cycleKind}
|
||||
icon={<GitBranch className="size-4" />}
|
||||
title="Git repository"
|
||||
caption="Initializes an empty Git repo"
|
||||
/>
|
||||
<KindCard
|
||||
kind="folder"
|
||||
selected={createKind === 'folder'}
|
||||
disabled={isCreating}
|
||||
onSelect={() => onKindChange('folder')}
|
||||
onArrowNav={cycleKind}
|
||||
icon={<Folder className="size-4" />}
|
||||
title="Folder"
|
||||
caption="Create a new folder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name. Monospaced because it ends up as a directory name. */}
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="create-project-name"
|
||||
className="text-[11px] font-medium text-muted-foreground block"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="create-project-name"
|
||||
value={createName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="my-project"
|
||||
className="h-11 text-sm font-mono"
|
||||
disabled={isCreating}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location. The "Choose…" button morphs into a summary + Change once picked. */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-medium text-muted-foreground block">Location</span>
|
||||
|
||||
{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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPickParent}
|
||||
disabled={isCreating}
|
||||
className="shrink-0 inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:cursor-not-allowed"
|
||||
aria-label="Change parent folder"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onPickParent}
|
||||
disabled={isCreating}
|
||||
className="w-full h-11 justify-start text-sm text-muted-foreground font-normal gap-2.5"
|
||||
>
|
||||
<span className="shrink-0 inline-flex items-center justify-center size-7 rounded-md border border-border/70 bg-background/40">
|
||||
<Folder className="size-3.5" />
|
||||
</span>
|
||||
Choose parent folder…
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<p className="text-[11px] text-destructive" role="alert">
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button onClick={onCreate} disabled={!canSubmit} size="lg" className="w-full">
|
||||
{isCreating ? 'Creating…' : 'Create project'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { FolderOpen, GitBranchPlus, Settings, ArrowLeft, Globe, Monitor } from 'lucide-react'
|
||||
import { FolderOpen, ArrowLeft, Globe, Monitor } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { LinkedWorktreeItem } from './LinkedWorktreeItem'
|
||||
import { RemoteStep, CloneStep, useRemoteRepo } from './AddRepoSteps'
|
||||
import { CreateStep, useCreateRepo } from './AddRepoCreateStep'
|
||||
import { SetupStep } from './AddRepoSetupStep'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
|
||||
@@ -27,7 +28,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
|
||||
const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'setup'>('add')
|
||||
const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'create' | 'setup'>('add')
|
||||
const [addedRepo, setAddedRepo] = useState<Repo | null>(null)
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const [cloneUrl, setCloneUrl] = useState('')
|
||||
@@ -55,6 +56,20 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
handleAddRemoteRepo,
|
||||
handleConnectTarget
|
||||
} = useRemoteRepo(fetchWorktrees, setStep, setAddedRepo, closeModal)
|
||||
|
||||
const {
|
||||
createName,
|
||||
createParent,
|
||||
createKind,
|
||||
createError,
|
||||
isCreating,
|
||||
setCreateName,
|
||||
setCreateKind,
|
||||
setCreateError,
|
||||
resetCreateState,
|
||||
handlePickParent,
|
||||
handleCreate
|
||||
} = useCreateRepo(fetchWorktrees, setStep, setAddedRepo, closeModal)
|
||||
useEffect(() => {
|
||||
if (!isCloning) {
|
||||
return
|
||||
@@ -79,8 +94,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
})
|
||||
}, [worktrees])
|
||||
|
||||
const hasWorktrees = worktrees.length > 0
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
cloneGenRef.current++
|
||||
// Why: kill the git clone process if one is running, so backing out
|
||||
@@ -94,8 +107,9 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
setIsCloning(false)
|
||||
setCloneError(null)
|
||||
setCloneProgress(null)
|
||||
resetCreateState()
|
||||
resetRemoteState()
|
||||
}, [resetRemoteState])
|
||||
}, [resetRemoteState, resetCreateState])
|
||||
|
||||
// Why: reset state on close so reopening doesn't show stale step/repo.
|
||||
useEffect(() => {
|
||||
@@ -104,7 +118,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
}
|
||||
}, [isOpen, resetState])
|
||||
|
||||
const isInputStep = step === 'add' || step === 'clone' || step === 'remote'
|
||||
const isInputStep = step === 'add' || step === 'clone' || step === 'remote' || step === 'create'
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
setIsAdding(true)
|
||||
@@ -217,7 +231,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
{/* Step indicator row — back button (step 2 only), dots, X is rendered by DialogContent */}
|
||||
<div className="flex items-center justify-center -mt-1">
|
||||
{(step === 'clone' || step === 'remote') && (
|
||||
{(step === 'clone' || step === 'remote' || step === 'create') && (
|
||||
<button
|
||||
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
onClick={handleBack}
|
||||
@@ -300,6 +314,21 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Secondary link rather than a fourth card — create-from-scratch
|
||||
is a less common path than importing. See orca#763. */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreateError(null)
|
||||
setStep('create')
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
Or start a new project from scratch
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : step === 'remote' ? (
|
||||
<RemoteStep
|
||||
@@ -342,64 +371,36 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
onPickDestination={handlePickDestination}
|
||||
onClone={handleClone}
|
||||
/>
|
||||
) : step === 'create' ? (
|
||||
<CreateStep
|
||||
createName={createName}
|
||||
createParent={createParent}
|
||||
createKind={createKind}
|
||||
createError={createError}
|
||||
isCreating={isCreating}
|
||||
onNameChange={(value) => {
|
||||
setCreateName(value)
|
||||
setCreateError(null)
|
||||
}}
|
||||
onKindChange={(kind) => {
|
||||
setCreateKind(kind)
|
||||
setCreateError(null)
|
||||
}}
|
||||
onPickParent={handlePickParent}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{hasWorktrees ? 'Open or create a worktree' : 'Set up your first worktree'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{hasWorktrees
|
||||
? `${addedRepo?.displayName} has ${worktrees.length} worktree${worktrees.length !== 1 ? 's' : ''}. Open one to pick up where you left off, or create a new one.`
|
||||
: `Orca uses git worktrees as isolated task environments. Create one for ${addedRepo?.displayName} to get started.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{hasWorktrees && (
|
||||
<div className="space-y-2 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Existing worktrees
|
||||
</p>
|
||||
<div className="space-y-1.5 max-h-[40vh] overflow-y-auto scrollbar-sleek pr-1">
|
||||
{sortedWorktrees.map((wt) => (
|
||||
<LinkedWorktreeItem
|
||||
key={wt.id}
|
||||
worktree={wt}
|
||||
onOpen={() => handleOpenWorktree(wt)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Button onClick={handleCreateWorktree} className="w-full">
|
||||
<GitBranchPlus className="size-4 mr-2" />
|
||||
{hasWorktrees ? 'Create new worktree' : 'Create first worktree'}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="inline-flex items-center justify-center gap-1.5 text-xs text-muted-foreground/70 hover:text-foreground transition-colors cursor-pointer"
|
||||
onClick={handleConfigureRepo}
|
||||
>
|
||||
<Settings className="size-3" />
|
||||
Configure project
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
closeModal()
|
||||
resetState()
|
||||
}}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<SetupStep
|
||||
repoName={addedRepo?.displayName ?? ''}
|
||||
sortedWorktrees={sortedWorktrees}
|
||||
onOpenWorktree={handleOpenWorktree}
|
||||
onCreateWorktree={handleCreateWorktree}
|
||||
onConfigureRepo={handleConfigureRepo}
|
||||
onSkip={() => {
|
||||
closeModal()
|
||||
resetState()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Setup step for AddRepoDialog — shown after a repo is added, cloned, or created.
|
||||
* Split out so the parent dialog stays under the 400-line oxlint limit.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { GitBranchPlus, Settings } from 'lucide-react'
|
||||
import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LinkedWorktreeItem } from './LinkedWorktreeItem'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
|
||||
type SetupStepProps = {
|
||||
repoName: string
|
||||
sortedWorktrees: Worktree[]
|
||||
onOpenWorktree: (worktree: Worktree) => void
|
||||
onCreateWorktree: () => void
|
||||
onConfigureRepo: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export function SetupStep({
|
||||
repoName,
|
||||
sortedWorktrees,
|
||||
onOpenWorktree,
|
||||
onCreateWorktree,
|
||||
onConfigureRepo,
|
||||
onSkip
|
||||
}: SetupStepProps): React.JSX.Element {
|
||||
const hasWorktrees = sortedWorktrees.length > 0
|
||||
const worktreeCount = sortedWorktrees.length
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{hasWorktrees ? 'Open or create a worktree' : 'Set up your first worktree'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{hasWorktrees
|
||||
? `${repoName} has ${worktreeCount} worktree${worktreeCount !== 1 ? 's' : ''}. Open one to pick up where you left off, or create a new one.`
|
||||
: `Orca uses git worktrees as isolated task environments. Create one for ${repoName} to get started.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{hasWorktrees && (
|
||||
<div className="space-y-2 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Existing worktrees
|
||||
</p>
|
||||
<div className="space-y-1.5 max-h-[40vh] overflow-y-auto scrollbar-sleek pr-1">
|
||||
{sortedWorktrees.map((wt) => (
|
||||
<LinkedWorktreeItem key={wt.id} worktree={wt} onOpen={() => onOpenWorktree(wt)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Button onClick={onCreateWorktree} className="w-full">
|
||||
<GitBranchPlus className="size-4 mr-2" />
|
||||
{hasWorktrees ? 'Create new worktree' : 'Create first worktree'}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="inline-flex items-center justify-center gap-1.5 text-xs text-muted-foreground/70 hover:text-foreground transition-colors cursor-pointer"
|
||||
onClick={onConfigureRepo}
|
||||
>
|
||||
<Settings className="size-3" />
|
||||
Configure project
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={onSkip}>
|
||||
Skip
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types
|
||||
|
||||
export function useRemoteRepo(
|
||||
fetchWorktrees: (repoId: string) => Promise<void>,
|
||||
setStep: (step: 'add' | 'clone' | 'remote' | 'setup') => void,
|
||||
setStep: (step: 'add' | 'clone' | 'remote' | 'create' | 'setup') => void,
|
||||
setAddedRepo: (repo: Repo | null) => void,
|
||||
closeModal: () => void
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user