fix: address review findings (#2695)
This commit is contained in:
parent
669ade2313
commit
c93dd901a3
|
|
@ -1,4 +1,4 @@
|
|||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { parsePtySessionId } from './pty-session-id'
|
||||
|
||||
|
|
@ -8,7 +8,9 @@ export type WslSessionContext = {
|
|||
|
||||
export function getWslContextFromSessionId(sessionId: string): WslSessionContext | undefined {
|
||||
const worktreeId = parsePtySessionId(sessionId).worktreeId
|
||||
const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined
|
||||
const worktreePath = worktreeId
|
||||
? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath
|
||||
: undefined
|
||||
const wslInfo = worktreePath ? parseWslPath(worktreePath) : null
|
||||
return wslInfo ? { distro: wslInfo.distro } : undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -253,7 +253,8 @@ describe('registerWorktreeHandlers', () => {
|
|||
store.getAllWorktreeLineage,
|
||||
store.removeWorktreeLineage,
|
||||
killAllProcessesForWorktreeMock,
|
||||
getLocalPtyProviderMock
|
||||
getLocalPtyProviderMock,
|
||||
deleteWorktreeHistoryDirMock
|
||||
]) {
|
||||
m.mockReset()
|
||||
}
|
||||
|
|
@ -486,6 +487,52 @@ describe('registerWorktreeHandlers', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('creates an additional workspace for folder-mode repos without git worktree add', async () => {
|
||||
const repo = {
|
||||
id: 'repo-folder',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder' as const
|
||||
}
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => ({
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...meta
|
||||
}))
|
||||
|
||||
const result = (await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-folder',
|
||||
name: 'folder-session',
|
||||
createdWithAgent: 'codex'
|
||||
})) as { worktree: { id: string } }
|
||||
|
||||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(result.worktree).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^repo-folder::\/workspace\/folder::workspace:[0-9a-f-]{36}$/),
|
||||
repoId: 'repo-folder',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder-session',
|
||||
instanceId: expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||
createdWithAgent: 'codex'
|
||||
})
|
||||
)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||||
repoId: 'repo-folder'
|
||||
})
|
||||
})
|
||||
|
||||
it('checks out a selected existing local branch exactly', async () => {
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce([
|
||||
|
|
@ -2425,6 +2472,55 @@ describe('registerWorktreeHandlers', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('refuses to delete the root workspace for folder-mode repos', async () => {
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-folder',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
})
|
||||
|
||||
await expect(
|
||||
handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-folder::/workspace/folder'
|
||||
})
|
||||
).rejects.toThrow('Cannot delete the project root workspace')
|
||||
|
||||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kills PTYs before removing additional folder workspace metadata', async () => {
|
||||
const ptyProvider = {} as never
|
||||
const worktreeId = 'repo-folder::/workspace/folder::workspace:child-1'
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-folder',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
})
|
||||
getLocalPtyProviderMock.mockReturnValue(ptyProvider)
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId })
|
||||
|
||||
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, {
|
||||
runtime: runtimeStub,
|
||||
localProvider: ptyProvider
|
||||
})
|
||||
expect(killAllProcessesForWorktreeMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
store.removeWorktreeMeta.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
|
||||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||||
repoId: 'repo-folder'
|
||||
})
|
||||
})
|
||||
|
||||
it('runs the archive hook on remove when skipArchive is not set', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
removeWorktreeMock.mockResolvedValue(undefined)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { gitExecFileAsync } from '../git/runner'
|
|||
import { withWorktreeSpan } from '../observability/instrumentation'
|
||||
import { resolveGitHubPrStartPoint } from '../github/pr-start-point'
|
||||
import { getDefaultRemote } from '../git/repo'
|
||||
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
|
|
@ -84,6 +84,8 @@ import {
|
|||
isWorktreePathMissing
|
||||
} from '../worktree-removal-safety'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id'
|
||||
|
||||
const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000
|
||||
|
||||
|
|
@ -370,6 +372,139 @@ function stampAndMergeVisibleDetectedWorktree(
|
|||
return mergeWorktree(repo.id, detected, meta, repo.displayName)
|
||||
}
|
||||
|
||||
function getFolderWorkspaceRootId(repo: Repo): string {
|
||||
return `${repo.id}::${repo.path}`
|
||||
}
|
||||
|
||||
function getFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string {
|
||||
return `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}`
|
||||
}
|
||||
|
||||
function getFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string {
|
||||
const prefix = `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
|
||||
return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID()
|
||||
}
|
||||
|
||||
function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean {
|
||||
const rootId = getFolderWorkspaceRootId(repo)
|
||||
return (
|
||||
worktreeId === rootId ||
|
||||
worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`)
|
||||
)
|
||||
}
|
||||
|
||||
function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree {
|
||||
return {
|
||||
id: worktreeId,
|
||||
...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
|
||||
repoId: repo.id,
|
||||
path: repo.path,
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isMainWorktree: worktreeId === getFolderWorkspaceRootId(repo),
|
||||
displayName: meta.displayName || repo.displayName,
|
||||
comment: meta.comment || '',
|
||||
linkedIssue: meta.linkedIssue ?? null,
|
||||
linkedPR: meta.linkedPR ?? null,
|
||||
linkedLinearIssue: meta.linkedLinearIssue ?? null,
|
||||
linkedGitLabMR: meta.linkedGitLabMR ?? null,
|
||||
linkedGitLabIssue: meta.linkedGitLabIssue ?? null,
|
||||
isArchived: meta.isArchived ?? false,
|
||||
isUnread: meta.isUnread ?? false,
|
||||
isPinned: meta.isPinned ?? false,
|
||||
sortOrder: meta.sortOrder ?? 0,
|
||||
...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}),
|
||||
lastActivityAt: meta.lastActivityAt ?? 0,
|
||||
...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
|
||||
...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}),
|
||||
workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
|
||||
diffComments: meta.diffComments
|
||||
}
|
||||
}
|
||||
|
||||
function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
|
||||
const rootId = getFolderWorkspaceRootId(repo)
|
||||
const allMeta = store.getAllWorktreeMeta()
|
||||
const ids = Object.keys(allMeta).filter((worktreeId) =>
|
||||
isFolderWorkspaceIdForRepo(repo, worktreeId)
|
||||
)
|
||||
if (!ids.includes(rootId)) {
|
||||
ids.unshift(rootId)
|
||||
}
|
||||
|
||||
return ids
|
||||
.map((worktreeId) => {
|
||||
const existing = allMeta[worktreeId]
|
||||
const meta = existing?.instanceId
|
||||
? existing
|
||||
: store.setWorktreeMeta(worktreeId, {
|
||||
instanceId: getFolderWorkspaceInstanceIdentity(repo, worktreeId),
|
||||
...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() })
|
||||
})
|
||||
return mergeFolderWorkspace(repo, worktreeId, meta)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.id === rootId) {
|
||||
return -1
|
||||
}
|
||||
if (b.id === rootId) {
|
||||
return 1
|
||||
}
|
||||
return (b.createdAt ?? b.lastActivityAt) - (a.createdAt ?? a.lastActivityAt)
|
||||
})
|
||||
}
|
||||
|
||||
function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktree[] {
|
||||
const settings = store.getSettings()
|
||||
return listFolderWorkspaces(store, repo).map((worktree) =>
|
||||
toDetectedWorktree({
|
||||
repo,
|
||||
worktree,
|
||||
meta: store.getWorktreeMeta(worktree.id),
|
||||
settings,
|
||||
knownOrcaLayouts: [],
|
||||
isLegacyRepoForVisibility: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
|
||||
return buildFolderDetectedWorktrees(store, repo)
|
||||
.filter((worktree) => worktree.visible)
|
||||
.map((worktree) => {
|
||||
const meta = store.getWorktreeMeta(worktree.id)
|
||||
return mergeFolderWorkspace(repo, worktree.id, meta ?? store.setWorktreeMeta(worktree.id, {}))
|
||||
})
|
||||
}
|
||||
|
||||
function createFolderWorkspace(
|
||||
args: CreateWorktreeArgs,
|
||||
repo: Repo,
|
||||
store: Store
|
||||
): CreateWorktreeResult {
|
||||
const now = Date.now()
|
||||
const instanceId = randomUUID()
|
||||
const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId)
|
||||
const meta = store.setWorktreeMeta(worktreeId, {
|
||||
instanceId,
|
||||
displayName: args.displayName || args.name,
|
||||
lastActivityAt: now,
|
||||
createdAt: now,
|
||||
orcaCreatedAt: now,
|
||||
orcaCreationSource: 'desktop',
|
||||
...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}),
|
||||
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
|
||||
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
|
||||
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
|
||||
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
|
||||
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}),
|
||||
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
|
||||
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {})
|
||||
})
|
||||
return { worktree: mergeFolderWorkspace(repo, worktreeId, meta) }
|
||||
}
|
||||
|
||||
function buildDisconnectedDetectedWorktrees(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
|
|
@ -431,7 +566,7 @@ export function registerWorktreeHandlers(
|
|||
try {
|
||||
let gitWorktrees
|
||||
if (isFolderRepo(repo)) {
|
||||
gitWorktrees = [createFolderWorktree(repo)]
|
||||
return listVisibleFolderWorkspaces(store, repo)
|
||||
} else if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
if (!provider) {
|
||||
|
|
@ -497,7 +632,7 @@ export function registerWorktreeHandlers(
|
|||
try {
|
||||
let gitWorktrees
|
||||
if (isFolderRepo(repo)) {
|
||||
gitWorktrees = [createFolderWorktree(repo)]
|
||||
return listVisibleFolderWorkspaces(store, repo)
|
||||
} else if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
if (!provider) {
|
||||
|
|
@ -561,7 +696,12 @@ export function registerWorktreeHandlers(
|
|||
try {
|
||||
let gitWorktrees: GitWorktreeInfo[]
|
||||
if (isFolderRepo(repo)) {
|
||||
gitWorktrees = [createFolderWorktree(repo)]
|
||||
return {
|
||||
repoId: repo.id,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: buildFolderDetectedWorktrees(store, repo)
|
||||
}
|
||||
} else if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
if (!provider) {
|
||||
|
|
@ -623,9 +763,6 @@ export function registerWorktreeHandlers(
|
|||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${args.repoId}`)
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder mode does not support creating worktrees.')
|
||||
}
|
||||
|
||||
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
|
||||
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
|
||||
|
|
@ -637,9 +774,11 @@ export function registerWorktreeHandlers(
|
|||
// worktrees`) signal IPC-shape bugs, not the user-visible
|
||||
// git/filesystem failures the funnel cares about — bucketing them
|
||||
// into `unknown` would pollute the failure taxonomy.
|
||||
result = repo.connectionId
|
||||
? await createRemoteWorktree(args, repo, store, mainWindow)
|
||||
: await createLocalWorktree(args, repo, store, mainWindow, runtime)
|
||||
result = isFolderRepo(repo)
|
||||
? createFolderWorkspace(args, repo, store)
|
||||
: repo.connectionId
|
||||
? await createRemoteWorktree(args, repo, store, mainWindow)
|
||||
: await createLocalWorktree(args, repo, store, mainWindow, runtime)
|
||||
} catch (error) {
|
||||
track('workspace_create_failed', {
|
||||
source,
|
||||
|
|
@ -659,10 +798,17 @@ export function registerWorktreeHandlers(
|
|||
// the branch name itself.
|
||||
track('workspace_created', {
|
||||
source,
|
||||
from_existing_branch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0,
|
||||
from_existing_branch:
|
||||
!isFolderRepo(repo) &&
|
||||
typeof args.baseBranch === 'string' &&
|
||||
args.baseBranch.length > 0,
|
||||
...getCohortAtEmit()
|
||||
})
|
||||
|
||||
if (isFolderRepo(repo)) {
|
||||
notifyWorktreesChanged(mainWindow, repo.id)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
|
@ -768,7 +914,23 @@ export function registerWorktreeHandlers(
|
|||
throw new Error(`Repo not found: ${repoId}`)
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder mode does not support deleting worktrees.')
|
||||
if (args.worktreeId === getFolderWorkspaceRootId(repo)) {
|
||||
throw new Error(
|
||||
'Cannot delete the project root workspace. Remove the folder project instead.'
|
||||
)
|
||||
}
|
||||
// Why: folder workspaces share one filesystem root, so there is no Git
|
||||
// remove step to close shells; sweep PTYs before dropping metadata.
|
||||
await killAllProcessesForWorktree(args.worktreeId, {
|
||||
runtime,
|
||||
localProvider: getLocalPtyProvider()
|
||||
}).catch((err) => {
|
||||
console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err)
|
||||
})
|
||||
store.removeWorktreeMeta(args.worktreeId)
|
||||
deleteWorktreeHistoryDir(args.worktreeId)
|
||||
notifyWorktreesChanged(mainWindow, repoId)
|
||||
return
|
||||
}
|
||||
|
||||
// Why: the renderer-supplied worktreeId contains a filesystem path.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { basename } from 'node:path'
|
|||
import { exec } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import os from 'node:os'
|
||||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
import { app } from 'electron'
|
||||
import type {
|
||||
AppMemory,
|
||||
|
|
@ -375,7 +375,7 @@ function resolveWorktreeNames(
|
|||
repoName: string
|
||||
} {
|
||||
// Orca worktree ids look like `${repoId}::${absolutePath}`.
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
const parsed = splitWorktreeIdForFilesystem(worktreeId)
|
||||
const repoId = parsed?.repoId ?? worktreeId
|
||||
const worktreePath = parsed?.worktreePath ?? ''
|
||||
const fallbackName = worktreePath ? basename(worktreePath) : worktreeId
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import path from 'path'
|
||||
import type { Store } from '../persistence'
|
||||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import type {
|
||||
WorkspacePortKillRequest,
|
||||
WorkspacePortKillResult,
|
||||
|
|
@ -27,12 +28,15 @@ export function getStoreWorkspacePortProbes(
|
|||
if (!repo || repo.connectionId) {
|
||||
return []
|
||||
}
|
||||
const worktreePath = isFolderRepo(repo)
|
||||
? (splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? parsed.worktreePath)
|
||||
: parsed.worktreePath
|
||||
return [
|
||||
{
|
||||
id: worktreeId,
|
||||
repoId: parsed.repoId,
|
||||
displayName: meta.displayName || path.basename(parsed.worktreePath),
|
||||
path: parsed.worktreePath
|
||||
displayName: meta.displayName || path.basename(worktreePath),
|
||||
path: worktreePath
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
|
|||
|
|
@ -68,7 +68,8 @@ const {
|
|||
reopenGitLabMRMock,
|
||||
getGlabKnownHostsMock,
|
||||
getGitLabWorkItemDetailsMock,
|
||||
getIssueMock
|
||||
getIssueMock,
|
||||
deleteWorktreeHistoryDirMock
|
||||
} = vi.hoisted(() => {
|
||||
// Why: SSH runtime tests register providers through the public dispatcher API,
|
||||
// so the mock needs the same registry semantics as the real module.
|
||||
|
|
@ -120,7 +121,8 @@ const {
|
|||
reopenGitLabMRMock: vi.fn(),
|
||||
getGlabKnownHostsMock: vi.fn(),
|
||||
getGitLabWorkItemDetailsMock: vi.fn(),
|
||||
getIssueMock: vi.fn()
|
||||
getIssueMock: vi.fn(),
|
||||
deleteWorktreeHistoryDirMock: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -131,6 +133,10 @@ vi.mock('../git/worktree', () => ({
|
|||
removeWorktree: removeWorktreeMock
|
||||
}))
|
||||
|
||||
vi.mock('../terminal-history', () => ({
|
||||
deleteWorktreeHistoryDir: deleteWorktreeHistoryDirMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE:
|
||||
|
|
@ -917,6 +923,102 @@ describe('OrcaRuntimeService', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('creates additional workspace metadata for folder-mode repos through runtime create', async () => {
|
||||
const folderRepo = {
|
||||
id: 'folder-repo',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'Folder',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
kind: 'folder' as const
|
||||
}
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [folderRepo],
|
||||
getRepo: (id: string) => (id === folderRepo.id ? folderRepo : undefined),
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
removeWorktreeMeta: (worktreeId: string) => {
|
||||
delete metaById[worktreeId]
|
||||
}
|
||||
}
|
||||
let deletedWorktreeId = ''
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn(async () => [{ id: `${deletedWorktreeId}@@pty-1` }]),
|
||||
shutdown: vi.fn(async () => undefined)
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
|
||||
getLocalProvider: () => localProvider as never
|
||||
})
|
||||
const notifier = { worktreesChanged: vi.fn() }
|
||||
runtime.setNotifier(notifier as never)
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:folder-repo',
|
||||
name: 'folder-session',
|
||||
createdWithAgent: 'codex'
|
||||
})
|
||||
|
||||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(result.worktree).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^folder-repo::\/workspace\/folder::workspace:[0-9a-f-]{36}$/),
|
||||
repoId: 'folder-repo',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder-session',
|
||||
isMainWorktree: false,
|
||||
createdWithAgent: 'codex'
|
||||
})
|
||||
)
|
||||
expect(metaById[result.worktree.id]).toMatchObject({
|
||||
instanceId: result.worktree.instanceId,
|
||||
displayName: 'folder-session',
|
||||
orcaCreationSource: 'runtime',
|
||||
createdWithAgent: 'codex'
|
||||
})
|
||||
await expect(runtime.showManagedWorktree(`id:${result.worktree.id}`)).resolves.toMatchObject({
|
||||
id: result.worktree.id,
|
||||
repoId: 'folder-repo',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder-session'
|
||||
})
|
||||
await expect(runtime.listManagedWorktrees('id:folder-repo')).resolves.toMatchObject({
|
||||
totalCount: 2,
|
||||
worktrees: [
|
||||
expect.objectContaining({
|
||||
id: 'folder-repo::/workspace/folder',
|
||||
isMainWorktree: true
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: result.worktree.id,
|
||||
isMainWorktree: false
|
||||
})
|
||||
]
|
||||
})
|
||||
await expect(
|
||||
runtime.updateManagedWorktreeMeta(`id:${result.worktree.id}`, { comment: 'note' })
|
||||
).resolves.toMatchObject({
|
||||
id: result.worktree.id,
|
||||
comment: 'note'
|
||||
})
|
||||
await expect(
|
||||
runtime.removeManagedWorktree('id:folder-repo::/workspace/folder')
|
||||
).rejects.toThrow('Cannot delete the project root workspace')
|
||||
deletedWorktreeId = result.worktree.id
|
||||
await expect(runtime.removeManagedWorktree(`id:${result.worktree.id}`)).resolves.toEqual({})
|
||||
expect(localProvider.shutdown).toHaveBeenCalledWith(`${result.worktree.id}@@pty-1`, {
|
||||
immediate: true
|
||||
})
|
||||
expect(metaById[result.worktree.id]).toBeUndefined()
|
||||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(result.worktree.id)
|
||||
expect(notifier.worktreesChanged).toHaveBeenCalledWith('folder-repo')
|
||||
})
|
||||
|
||||
it('refreshes runtime remote-tracking bases before creating local worktrees', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const refresh = deferred<{ stdout: string; stderr: string }>()
|
||||
|
|
|
|||
|
|
@ -46,8 +46,9 @@ import type {
|
|||
TabGroupLayoutNode,
|
||||
TuiAgent
|
||||
} from '../../shared/types'
|
||||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
|
||||
import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
|
||||
import { FIRST_PANE_ID } from '../../shared/pane-key'
|
||||
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id'
|
||||
|
|
@ -317,8 +318,9 @@ import {
|
|||
writeIssueCommand
|
||||
} from '../hooks'
|
||||
import { DEFAULT_REPO_BADGE_COLOR, getDefaultVoiceSettings } from '../../shared/constants'
|
||||
import { createFolderWorktree, listRepoWorktrees } from '../repo-worktrees'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import { createWorktreeSymlinks } from '../ipc/worktree-symlinks'
|
||||
import { deleteWorktreeHistoryDir } from '../terminal-history'
|
||||
import {
|
||||
cleanupUnusedWorktreePushTargetRemote,
|
||||
cleanupUnusedWorktreePushTargetRemoteSsh,
|
||||
|
|
@ -704,6 +706,92 @@ function getRuntimeWorktreeRemovalOptionsKey(force: boolean, runHooks: boolean):
|
|||
return `${force ? 'force' : 'normal'}:${runHooks ? 'run-hooks' : 'skip-hooks'}`
|
||||
}
|
||||
|
||||
function getRuntimeFolderWorkspaceRootId(repo: Repo): string {
|
||||
return `${repo.id}::${repo.path}`
|
||||
}
|
||||
|
||||
function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string {
|
||||
return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}`
|
||||
}
|
||||
|
||||
function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string {
|
||||
const prefix = `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
|
||||
return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID()
|
||||
}
|
||||
|
||||
function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean {
|
||||
const rootId = getRuntimeFolderWorkspaceRootId(repo)
|
||||
return (
|
||||
worktreeId === rootId ||
|
||||
worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`)
|
||||
)
|
||||
}
|
||||
|
||||
function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree {
|
||||
return {
|
||||
id: worktreeId,
|
||||
...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
|
||||
repoId: repo.id,
|
||||
path: repo.path,
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isMainWorktree: worktreeId === getRuntimeFolderWorkspaceRootId(repo),
|
||||
displayName: meta.displayName || repo.displayName,
|
||||
comment: meta.comment || '',
|
||||
linkedIssue: meta.linkedIssue ?? null,
|
||||
linkedPR: meta.linkedPR ?? null,
|
||||
linkedLinearIssue: meta.linkedLinearIssue ?? null,
|
||||
linkedGitLabMR: meta.linkedGitLabMR ?? null,
|
||||
linkedGitLabIssue: meta.linkedGitLabIssue ?? null,
|
||||
isArchived: meta.isArchived ?? false,
|
||||
isUnread: meta.isUnread ?? false,
|
||||
isPinned: meta.isPinned ?? false,
|
||||
sortOrder: meta.sortOrder ?? 0,
|
||||
...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}),
|
||||
lastActivityAt: meta.lastActivityAt ?? 0,
|
||||
...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
|
||||
...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}),
|
||||
workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
|
||||
diffComments: meta.diffComments
|
||||
}
|
||||
}
|
||||
|
||||
function listRuntimeFolderWorkspaces(
|
||||
store: Pick<RuntimeStore, 'getAllWorktreeMeta' | 'setWorktreeMeta'>,
|
||||
repo: Repo
|
||||
): Worktree[] {
|
||||
const rootId = getRuntimeFolderWorkspaceRootId(repo)
|
||||
const allMeta = store.getAllWorktreeMeta()
|
||||
const ids = Object.keys(allMeta).filter((worktreeId) =>
|
||||
isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId)
|
||||
)
|
||||
if (!ids.includes(rootId)) {
|
||||
ids.unshift(rootId)
|
||||
} else {
|
||||
ids.sort((left, right) => {
|
||||
if (left === rootId) {
|
||||
return -1
|
||||
}
|
||||
if (right === rootId) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
return ids.map((worktreeId) => {
|
||||
const existing = allMeta[worktreeId]
|
||||
const meta = existing?.instanceId
|
||||
? existing
|
||||
: store.setWorktreeMeta(worktreeId, {
|
||||
instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId),
|
||||
...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() })
|
||||
})
|
||||
return mergeRuntimeFolderWorkspace(repo, worktreeId, meta)
|
||||
})
|
||||
}
|
||||
|
||||
function parseExactWorktreeIdSelector(selector: string): RuntimeWorktreeRemovalTarget | null {
|
||||
const worktreeId = selector.startsWith('id:') ? selector.slice(3) : selector
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
|
|
@ -6417,11 +6505,18 @@ export class OrcaRuntimeService {
|
|||
|
||||
async listDetectedManagedWorktrees(repoSelector: string): Promise<DetectedWorktreeListResult> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
if (isFolderRepo(repo)) {
|
||||
const worktrees = listRuntimeFolderWorkspaces(this.requireStore(), repo)
|
||||
return {
|
||||
repoId: repo.id,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree))
|
||||
}
|
||||
}
|
||||
let scan: RuntimeWorktreeScanResult
|
||||
try {
|
||||
scan = isFolderRepo(repo)
|
||||
? { ok: true, worktrees: [createFolderWorktree(repo)] }
|
||||
: await this.listRepoWorktreesForResolution(repo)
|
||||
scan = await this.listRepoWorktreesForResolution(repo)
|
||||
} catch {
|
||||
scan = { ok: false, worktrees: [] }
|
||||
}
|
||||
|
|
@ -6902,15 +6997,102 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
|
||||
const repo = await this.resolveRepoSelector(args.repoSelector)
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder mode does not support creating worktrees.')
|
||||
}
|
||||
const draftStartup = args.startupDraft
|
||||
? await this.buildStartupForDraft(repo, args.startupDraft, args.createdWithAgent)
|
||||
: null
|
||||
const effectiveStartup = args.startup ?? draftStartup?.startup
|
||||
const effectiveCreatedWithAgent = args.createdWithAgent ?? draftStartup?.agent
|
||||
const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste
|
||||
if (isFolderRepo(repo)) {
|
||||
const now = Date.now()
|
||||
const settings = this.store.getSettings()
|
||||
const instanceId = randomUUID()
|
||||
const worktreeId = getRuntimeFolderWorkspaceInstanceId(repo, instanceId)
|
||||
const meta = this.store.setWorktreeMeta(worktreeId, {
|
||||
instanceId,
|
||||
displayName: args.displayName?.trim() || args.name,
|
||||
lastActivityAt: now,
|
||||
createdAt: now,
|
||||
orcaCreatedAt: now,
|
||||
orcaCreationSource: 'runtime',
|
||||
orcaCreationWorkspaceLayout: {
|
||||
path: settings.workspaceDir,
|
||||
nestWorkspaces: settings.nestWorkspaces
|
||||
},
|
||||
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
|
||||
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
|
||||
...(args.linkedLinearIssue !== undefined
|
||||
? { linkedLinearIssue: args.linkedLinearIssue }
|
||||
: {}),
|
||||
...(args.linkedGitLabIssue !== undefined
|
||||
? { linkedGitLabIssue: args.linkedGitLabIssue }
|
||||
: {}),
|
||||
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
|
||||
...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}),
|
||||
...(args.comment !== undefined ? { comment: args.comment } : {}),
|
||||
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
|
||||
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
|
||||
})
|
||||
const worktree = mergeRuntimeFolderWorkspace(repo, worktreeId, meta)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.notifier?.worktreesChanged(repo.id)
|
||||
const shouldActivate = args.activate === true || args.runHooks === true
|
||||
let warning: string | undefined
|
||||
let didSpawnStartup = false
|
||||
if (effectiveStartup && this.ptyController?.spawn) {
|
||||
try {
|
||||
const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent
|
||||
if (startupTrustAgent) {
|
||||
this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktree.path)
|
||||
}
|
||||
const terminal = await this.createTerminal(`id:${worktree.id}`, {
|
||||
command: effectiveStartup.command,
|
||||
env: effectiveStartup.env
|
||||
})
|
||||
if (effectiveDraftPaste) {
|
||||
this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste)
|
||||
}
|
||||
didSpawnStartup = true
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
warning = `Failed to create the startup terminal for ${worktree.path}: ${message}`
|
||||
console.warn(`[worktree-create] ${warning}`)
|
||||
}
|
||||
}
|
||||
if (shouldActivate) {
|
||||
if (effectiveStartup && !didSpawnStartup) {
|
||||
this.notifier?.activateWorktree(repo.id, worktree.id, undefined, effectiveStartup)
|
||||
} else {
|
||||
this.notifier?.activateWorktree(repo.id, worktree.id)
|
||||
}
|
||||
} else if (this.ptyController?.spawn && !didSpawnStartup) {
|
||||
try {
|
||||
await this.createTerminal(`id:${worktree.id}`)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
warning = warning
|
||||
? `${warning} Also failed to create the initial terminal for ${worktree.path}: ${message}`
|
||||
: `Failed to create the initial terminal for ${worktree.path}: ${message}`
|
||||
console.warn(`[worktree-create] ${warning}`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
worktree: {
|
||||
...worktree,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
git: {
|
||||
path: worktree.path,
|
||||
head: worktree.head,
|
||||
branch: worktree.branch,
|
||||
isBare: worktree.isBare,
|
||||
isMainWorktree: worktree.isMainWorktree
|
||||
}
|
||||
},
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
return await this.createManagedRemoteWorktree(repo, {
|
||||
...args,
|
||||
|
|
@ -8259,7 +8441,27 @@ export class OrcaRuntimeService {
|
|||
throw new Error('repo_not_found')
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder mode does not support deleting worktrees.')
|
||||
if (removalTarget.id === getRuntimeFolderWorkspaceRootId(repo)) {
|
||||
throw new Error(
|
||||
'Cannot delete the project root workspace. Remove the folder project instead.'
|
||||
)
|
||||
}
|
||||
const localProvider = this.getLocalProvider()
|
||||
if (localProvider) {
|
||||
// Why: folder workspace deletion has no Git removal phase where PTYs
|
||||
// would otherwise be swept; tear them down before hiding the workspace.
|
||||
await killAllProcessesForWorktree(removalTarget.id, {
|
||||
runtime: this,
|
||||
localProvider
|
||||
}).catch((err) => {
|
||||
console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err)
|
||||
})
|
||||
}
|
||||
store.removeWorktreeMeta(removalTarget.id)
|
||||
deleteWorktreeHistoryDir(removalTarget.id)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.notifier?.worktreesChanged(repo.id)
|
||||
return {}
|
||||
}
|
||||
const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null
|
||||
const registeredWorktrees = repo.connectionId
|
||||
|
|
@ -9613,6 +9815,23 @@ export class OrcaRuntimeService {
|
|||
const now = Date.now()
|
||||
const perRepoWorktrees = await Promise.all(
|
||||
this.store.getRepos().map(async (repo) => {
|
||||
if (isFolderRepo(repo)) {
|
||||
return listRuntimeFolderWorkspaces(this.requireStore(), repo).map((worktree) => ({
|
||||
...worktree,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
git: {
|
||||
path: worktree.path,
|
||||
head: worktree.head,
|
||||
branch: worktree.branch,
|
||||
isBare: worktree.isBare,
|
||||
isMainWorktree: worktree.isMainWorktree
|
||||
},
|
||||
displayName: worktree.displayName,
|
||||
comment: worktree.comment
|
||||
}))
|
||||
}
|
||||
// Why: mobile startup RPCs share this path. A slow repo scan should
|
||||
// degrade one repo's metadata, not block all terminal/session loading.
|
||||
const scan = await withTimeout(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { basename } from 'path'
|
||||
import type { Repo } from '../shared/types'
|
||||
import { splitWorktreeId } from '../shared/worktree-id'
|
||||
import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../shared/worktree-id'
|
||||
import { isFolderRepo } from '../shared/repo-kind'
|
||||
import type { Store } from './persistence'
|
||||
|
||||
export type UsageWorktreeRef = {
|
||||
|
|
@ -40,15 +41,20 @@ export function loadKnownUsageWorktreesByRepo(
|
|||
if (!parsed || !repoIds.has(parsed.repoId)) {
|
||||
continue
|
||||
}
|
||||
const repo = localRepos.find((item) => item.id === parsed.repoId)
|
||||
const worktreePath =
|
||||
repo && isFolderRepo(repo)
|
||||
? (splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? parsed.worktreePath)
|
||||
: parsed.worktreePath
|
||||
const seenPaths = seenPathsByRepo.get(parsed.repoId)
|
||||
if (seenPaths?.has(parsed.worktreePath)) {
|
||||
if (seenPaths?.has(worktreePath)) {
|
||||
continue
|
||||
}
|
||||
seenPaths?.add(parsed.worktreePath)
|
||||
seenPaths?.add(worktreePath)
|
||||
worktreesByRepo.get(parsed.repoId)?.push({
|
||||
worktreeId,
|
||||
path: parsed.worktreePath,
|
||||
displayName: meta.displayName || getDefaultUsageWorktreeLabel(parsed.worktreePath)
|
||||
path: worktreePath,
|
||||
displayName: meta.displayName || getDefaultUsageWorktreeLabel(worktreePath)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,7 @@ function getPreflightIssues(status: {
|
|||
issues.push({
|
||||
id: 'git',
|
||||
title: 'Git is not installed',
|
||||
description:
|
||||
'Git is required for Git repositories, source control, and workspace management.',
|
||||
description: 'Git is required for Git projects, source control, and workspace management.',
|
||||
fixLabel: 'Install Git',
|
||||
fixUrl: 'https://git-scm.com/downloads'
|
||||
})
|
||||
|
|
@ -194,7 +193,9 @@ export default function Landing(): React.JSX.Element {
|
|||
const repos = useAppStore((s) => s.repos)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
|
||||
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
|
||||
const canCreateWorktree = repos.length > 0
|
||||
const createTargetLabel =
|
||||
canCreateWorktree && repos.every((repo) => isGitRepoKind(repo)) ? 'Worktree' : 'Workspace'
|
||||
|
||||
const [preflightIssues, setPreflightIssues] = useState<PreflightIssue[]>([])
|
||||
|
||||
|
|
@ -250,11 +251,15 @@ export default function Landing(): React.JSX.Element {
|
|||
const nextWorktreeKeys = useShortcutKeys('worktree.navigateDown')
|
||||
const shortcuts = useMemo<ShortcutItem[]>(() => {
|
||||
return [
|
||||
{ id: 'create', keys: createWorktreeKeys, action: 'Create workspace' },
|
||||
{
|
||||
id: 'create',
|
||||
keys: createWorktreeKeys,
|
||||
action: `Create ${createTargetLabel.toLowerCase()}`
|
||||
},
|
||||
{ id: 'up', keys: previousWorktreeKeys, action: 'Move up workspace' },
|
||||
{ id: 'down', keys: nextWorktreeKeys, action: 'Move down workspace' }
|
||||
]
|
||||
}, [createWorktreeKeys, nextWorktreeKeys, previousWorktreeKeys])
|
||||
}, [createTargetLabel, createWorktreeKeys, nextWorktreeKeys, previousWorktreeKeys])
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
|
|
@ -288,11 +293,11 @@ export default function Landing(): React.JSX.Element {
|
|||
<button
|
||||
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-sm px-4 py-2 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed enabled:cursor-pointer enabled:hover:bg-accent"
|
||||
disabled={!canCreateWorktree}
|
||||
title={!canCreateWorktree ? 'Add a Git project first' : undefined}
|
||||
title={!canCreateWorktree ? 'Add a project first' : undefined}
|
||||
onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })}
|
||||
>
|
||||
<GitBranchPlus className="size-3.5" />
|
||||
Create Workspace
|
||||
Create {createTargetLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,9 @@ type NewWorkspaceComposerCardProps = {
|
|||
onQuickAgentChange: (agent: TuiAgent | null) => void
|
||||
eligibleRepos: RepoOption[]
|
||||
repoId: string
|
||||
selectedRepoIsGit: boolean
|
||||
onRepoChange: (value: string) => void
|
||||
primaryActionLabel: string
|
||||
name: string
|
||||
onNameValueChange: (value: string) => void
|
||||
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
|
||||
|
|
@ -207,7 +209,9 @@ export default function NewWorkspaceComposerCard({
|
|||
onQuickAgentChange,
|
||||
eligibleRepos,
|
||||
repoId,
|
||||
selectedRepoIsGit,
|
||||
onRepoChange,
|
||||
primaryActionLabel,
|
||||
name,
|
||||
onNameValueChange,
|
||||
onSmartGitHubItemSelect,
|
||||
|
|
@ -249,7 +253,7 @@ export default function NewWorkspaceComposerCard({
|
|||
const submitShortcutModifierLabel = getScreenSubmitModifierLabel()
|
||||
const selectedRepoName = React.useMemo(() => {
|
||||
const repo = eligibleRepos.find((candidate) => candidate.id === repoId)
|
||||
return repo?.displayName ?? repo?.path ?? 'This repository'
|
||||
return repo?.displayName ?? repo?.path ?? 'This project'
|
||||
}, [eligibleRepos, repoId])
|
||||
const sshStatusLabel = selectedRepoSshStatus
|
||||
? SSH_STATUS_LABELS[selectedRepoSshStatus]
|
||||
|
|
@ -314,7 +318,7 @@ export default function NewWorkspaceComposerCard({
|
|||
size="icon-xs"
|
||||
onClick={handleAddRepo}
|
||||
className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
|
||||
aria-label="Add folder or repository"
|
||||
aria-label="Add project"
|
||||
>
|
||||
<FolderPlus className="size-3" />
|
||||
</Button>
|
||||
|
|
@ -372,7 +376,7 @@ export default function NewWorkspaceComposerCard({
|
|||
|
||||
<div className="min-w-0 space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Name or 'Create From'{' '}
|
||||
{selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'}{' '}
|
||||
<span className="text-muted-foreground/70">[Optional]</span>
|
||||
</label>
|
||||
<SmartWorkspaceNameField
|
||||
|
|
@ -390,6 +394,7 @@ export default function NewWorkspaceComposerCard({
|
|||
onClearSelectedSource={onClearSmartNameSelection}
|
||||
disabled={selectedRepoRequiresConnection}
|
||||
disabledPlaceholder="Connect this repo first"
|
||||
textOnly={!selectedRepoIsGit}
|
||||
onPlainEnter={() => {
|
||||
// Why: Enter on the workspace name advances focus to the next
|
||||
// field (Agent combobox) rather than submitting, letting the user
|
||||
|
|
@ -611,7 +616,7 @@ export default function NewWorkspaceComposerCard({
|
|||
/>
|
||||
{!canUseSparseCheckout ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Only available for local repositories.
|
||||
Only available for local Git projects.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -645,7 +650,7 @@ export default function NewWorkspaceComposerCard({
|
|||
className="text-xs"
|
||||
>
|
||||
{creating ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
Create Workspace
|
||||
{primaryActionLabel}
|
||||
<span className="ml-1 inline-flex items-center gap-0.5 rounded border border-white/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-current/80">
|
||||
<span>{submitShortcutModifierLabel}</span>
|
||||
<CornerDownLeft className="size-3" />
|
||||
|
|
|
|||
|
|
@ -83,10 +83,6 @@ function ComposerModalBody({
|
|||
trigger?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="gap-1">
|
||||
<DialogTitle className="text-base font-semibold">Create Workspace</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<QuickTabBody modalData={modalData} onClose={onClose} active />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -155,6 +151,7 @@ function QuickTabBody({
|
|||
const handleCreate = useCallback(async (): Promise<void> => {
|
||||
await submitQuick(quickAgent)
|
||||
}, [quickAgent, submitQuick])
|
||||
const primaryActionLabel = cardProps.selectedRepoIsGit ? 'Create Worktree' : 'Create Workspace'
|
||||
|
||||
// Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page).
|
||||
useEffect(() => {
|
||||
|
|
@ -206,12 +203,16 @@ function QuickTabBody({
|
|||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="gap-1">
|
||||
<DialogTitle className="text-base font-semibold">{primaryActionLabel}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<NewWorkspaceComposerCard
|
||||
composerRef={composerRef}
|
||||
nameInputRef={nameInputRef}
|
||||
quickAgent={quickAgent}
|
||||
onQuickAgentChange={handleQuickAgentChange}
|
||||
{...cardProps}
|
||||
primaryActionLabel={primaryActionLabel}
|
||||
onOpenAgentSettings={() => setAgentSettingsOpen(true)}
|
||||
onCreate={() => void handleCreate()}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4305,14 +4305,14 @@ export default function TaskPage(): React.JSX.Element {
|
|||
onChange={(next) => {
|
||||
setRepoSelection(next)
|
||||
void updateSettings({ defaultRepoSelection: [...next] }).catch(() => {
|
||||
toast.error('Failed to save repo selection.')
|
||||
toast.error('Failed to save project selection.')
|
||||
})
|
||||
}}
|
||||
onSelectAll={() => {
|
||||
const allIds = new Set(eligibleRepos.map((r) => r.id))
|
||||
setRepoSelection(allIds)
|
||||
void updateSettings({ defaultRepoSelection: null }).catch(() => {
|
||||
toast.error('Failed to save repo selection.')
|
||||
toast.error('Failed to save project selection.')
|
||||
})
|
||||
}}
|
||||
triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
|
||||
|
|
@ -4667,14 +4667,14 @@ export default function TaskPage(): React.JSX.Element {
|
|||
onChange={(next) => {
|
||||
setRepoSelection(next)
|
||||
void updateSettings({ defaultRepoSelection: [...next] }).catch(() => {
|
||||
toast.error('Failed to save repo selection.')
|
||||
toast.error('Failed to save project selection.')
|
||||
})
|
||||
}}
|
||||
onSelectAll={() => {
|
||||
const allIds = new Set(eligibleRepos.map((r) => r.id))
|
||||
setRepoSelection(allIds)
|
||||
void updateSettings({ defaultRepoSelection: null }).catch(() => {
|
||||
toast.error('Failed to save repo selection.')
|
||||
toast.error('Failed to save project selection.')
|
||||
})
|
||||
}}
|
||||
triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
|
||||
|
|
@ -4823,7 +4823,7 @@ export default function TaskPage(): React.JSX.Element {
|
|||
// Why: per-repo partial-failure signal — distinct from a hard
|
||||
// IPC reject (tasksError). The two are mutually exclusive.
|
||||
<div className="border-b border-border/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
|
||||
{failedCount} of {selectedRepos.length} repos failed to load
|
||||
{failedCount} of {selectedRepos.length} projects failed to load
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -5177,7 +5177,7 @@ export default function TaskPage(): React.JSX.Element {
|
|||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
{primaryRepo
|
||||
? 'No pending todos. You’re all caught up!'
|
||||
: 'Select a repo so we can authenticate to GitLab.'}
|
||||
: 'Select a project so we can authenticate to GitLab.'}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="divide-y divide-border/50">
|
||||
|
|
@ -5271,7 +5271,7 @@ export default function TaskPage(): React.JSX.Element {
|
|||
: gitlabView === 'mrs'
|
||||
? 'No GitLab MRs match this filter.'
|
||||
: 'No GitLab work matches this filter.'
|
||||
: 'Select a repo to see GitLab work items.'}
|
||||
: 'Select a project to see GitLab work items.'}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="divide-y divide-border/50">
|
||||
|
|
@ -5990,7 +5990,7 @@ export default function TaskPage(): React.JSX.Element {
|
|||
<div className="flex flex-col gap-3">
|
||||
{selectedRepos.length > 1 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">Repository</label>
|
||||
<label className="text-[11px] font-medium text-muted-foreground">Project</label>
|
||||
<Select
|
||||
value={newIssueRepoId ?? undefined}
|
||||
onValueChange={(v) => setNewIssueRepoId(v)}
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
const preserveCreateLookupOnCloseRef = useRef(false)
|
||||
|
||||
const repoMap = useMemo(() => new Map(repos.map((r) => [r.id, r])), [repos])
|
||||
const canCreateWorktree = useMemo(() => repos.some((repo) => isGitRepoKind(repo)), [repos])
|
||||
const canCreateWorktree = repos.length > 0
|
||||
|
||||
const hasQuery = deferredQuery.trim().length > 0
|
||||
|
||||
|
|
@ -1182,8 +1182,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
</div>
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{deferredQuery.trim()
|
||||
? `${resultCount} results found${showCreateAction ? ', create new worktree action available' : ''}`
|
||||
: `${resultCount} items available${showCreateAction ? ', create new worktree action available' : ''}`}
|
||||
? `${resultCount} results found${showCreateAction ? ', create workspace action available' : ''}`
|
||||
: `${resultCount} items available${showCreateAction ? ', create workspace action available' : ''}`}
|
||||
</div>
|
||||
</CommandDialog>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ type SmartWorkspaceNameFieldProps = {
|
|||
onPlainEnter?: () => void
|
||||
disabled?: boolean
|
||||
disabledPlaceholder?: string
|
||||
textOnly?: boolean
|
||||
}
|
||||
|
||||
export type SmartWorkspaceNameSelection = {
|
||||
|
|
@ -147,7 +148,8 @@ export default function SmartWorkspaceNameField({
|
|||
inputRef,
|
||||
onPlainEnter,
|
||||
disabled = false,
|
||||
disabledPlaceholder
|
||||
disabledPlaceholder,
|
||||
textOnly = false
|
||||
}: SmartWorkspaceNameFieldProps): React.JSX.Element {
|
||||
const {
|
||||
addRepo,
|
||||
|
|
@ -182,7 +184,7 @@ export default function SmartWorkspaceNameField({
|
|||
() => repos.find((repo) => repo.id === repoId) ?? null,
|
||||
[repoId, repos]
|
||||
)
|
||||
const [mode, setMode] = useState<SmartNameMode>('smart')
|
||||
const [mode, setMode] = useState<SmartNameMode>(textOnly ? 'text' : 'smart')
|
||||
const [mrStateFilter, setMrStateFilter] = useState<MrStateFilter>('opened')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(value)
|
||||
|
|
@ -216,6 +218,9 @@ export default function SmartWorkspaceNameField({
|
|||
const availableModes = useMemo(
|
||||
() =>
|
||||
MODES.filter((item) => {
|
||||
if (textOnly) {
|
||||
return item.id === 'text'
|
||||
}
|
||||
if (item.id === 'gitlab') {
|
||||
return gitlabAvailable
|
||||
}
|
||||
|
|
@ -224,7 +229,7 @@ export default function SmartWorkspaceNameField({
|
|||
}
|
||||
return true
|
||||
}),
|
||||
[gitlabAvailable, linearAvailable]
|
||||
[gitlabAvailable, linearAvailable, textOnly]
|
||||
)
|
||||
|
||||
const setInputNode = useCallback(
|
||||
|
|
@ -238,7 +243,7 @@ export default function SmartWorkspaceNameField({
|
|||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
if (disabled || textOnly) {
|
||||
return
|
||||
}
|
||||
if (!preflightStatusChecked) {
|
||||
|
|
@ -252,10 +257,18 @@ export default function SmartWorkspaceNameField({
|
|||
disabled,
|
||||
linearStatusChecked,
|
||||
preflightStatusChecked,
|
||||
refreshPreflightStatus
|
||||
refreshPreflightStatus,
|
||||
textOnly
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (textOnly) {
|
||||
if (mode !== 'text') {
|
||||
setMode('text')
|
||||
}
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
if ((mode === 'gitlab' && gitlabAvailable) || (mode === 'linear' && linearAvailable)) {
|
||||
return
|
||||
}
|
||||
|
|
@ -268,7 +281,7 @@ export default function SmartWorkspaceNameField({
|
|||
setGitlabLoading(false)
|
||||
setLinearLoading(false)
|
||||
setCommandValue('')
|
||||
}, [gitlabAvailable, linearAvailable, mode])
|
||||
}, [gitlabAvailable, linearAvailable, mode, textOnly])
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
|
|
@ -303,9 +316,9 @@ export default function SmartWorkspaceNameField({
|
|||
[debouncedQuery]
|
||||
)
|
||||
const parsedGhLink = useMemo(() => parseGitHubIssueOrPRLink(debouncedQuery), [debouncedQuery])
|
||||
const shouldQueryGithub = mode === 'smart' || mode === 'github'
|
||||
const shouldQueryBranches = mode === 'smart' || mode === 'branches'
|
||||
const shouldQueryLinear = linearAvailable && (mode === 'smart' || mode === 'linear')
|
||||
const shouldQueryGithub = !textOnly && (mode === 'smart' || mode === 'github')
|
||||
const shouldQueryBranches = !textOnly && (mode === 'smart' || mode === 'branches')
|
||||
const shouldQueryLinear = !textOnly && linearAvailable && (mode === 'smart' || mode === 'linear')
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || !shouldQueryGithub || !selectedRepo?.path) {
|
||||
|
|
@ -512,7 +525,7 @@ export default function SmartWorkspaceNameField({
|
|||
// GitLabWorkItem via the IPC. Skipped silently when the host hook
|
||||
// hasn't supplied an onGitLabItemSelect handler.
|
||||
const parsedGlLink = useMemo(() => parseGitLabIssueOrMRLink(debouncedQuery), [debouncedQuery])
|
||||
const shouldQueryGitlab = gitlabAvailable && (mode === 'smart' || mode === 'gitlab')
|
||||
const shouldQueryGitlab = !textOnly && gitlabAvailable && (mode === 'smart' || mode === 'gitlab')
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldQueryGitlab ||
|
||||
|
|
@ -894,49 +907,44 @@ export default function SmartWorkspaceNameField({
|
|||
}}
|
||||
className="gap-0"
|
||||
>
|
||||
<TabsList
|
||||
ref={tabsListRef}
|
||||
variant="line"
|
||||
className="h-7 w-full justify-start gap-4 border-b border-border/40 px-0"
|
||||
onFocusCapture={(event) => {
|
||||
// Why: Radix Tabs uses roving focus and re-applies tabindex=0 to
|
||||
// the active trigger on every render, so we can't keep it out of
|
||||
// the natural Tab order via props or a MutationObserver (race
|
||||
// with React commits). Instead, intercept focus *on entry* into
|
||||
// the tabs list:
|
||||
// - Forward Tab from outside (e.g., Repo combobox) → bounce to
|
||||
// the search input so the segmented control is skipped.
|
||||
// - Shift-Tab from the input → relatedTarget is the input, so
|
||||
// allow focus to land on the active trigger (segmented
|
||||
// control remains reachable in reverse).
|
||||
// - Intra-list focus moves (arrow keys) → relatedTarget is
|
||||
// inside the list; allow.
|
||||
const previous = event.relatedTarget as HTMLElement | null
|
||||
const list = tabsListRef.current
|
||||
const input = localInputRef.current
|
||||
if (!list || !input) {
|
||||
return
|
||||
}
|
||||
if (!previous || previous === input || list.contains(previous)) {
|
||||
return
|
||||
}
|
||||
event.stopPropagation()
|
||||
input.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
{availableModes.map(({ id, label, Icon }) => (
|
||||
<TabsTrigger
|
||||
key={id}
|
||||
value={id}
|
||||
tabIndex={-1}
|
||||
data-smart-name-mode={id}
|
||||
className="flex-none gap-1.5 px-0 text-xs"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<span>{label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{textOnly ? null : (
|
||||
<TabsList
|
||||
ref={tabsListRef}
|
||||
variant="line"
|
||||
className="h-7 w-full justify-start gap-4 border-b border-border/40 px-0"
|
||||
onFocusCapture={(event) => {
|
||||
// Why: Radix Tabs uses roving focus and re-applies tabindex=0 to
|
||||
// the active trigger on every render, so we can't keep it out of
|
||||
// the natural Tab order via props or a MutationObserver (race
|
||||
// with React commits). Instead, intercept focus on entry into
|
||||
// the tabs list so forward Tab goes straight to the input.
|
||||
const previous = event.relatedTarget as HTMLElement | null
|
||||
const list = tabsListRef.current
|
||||
const input = localInputRef.current
|
||||
if (!list || !input) {
|
||||
return
|
||||
}
|
||||
if (!previous || previous === input || list.contains(previous)) {
|
||||
return
|
||||
}
|
||||
event.stopPropagation()
|
||||
input.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
{availableModes.map(({ id, label, Icon }) => (
|
||||
<TabsTrigger
|
||||
key={id}
|
||||
value={id}
|
||||
tabIndex={-1}
|
||||
data-smart-name-mode={id}
|
||||
className="flex-none gap-1.5 px-0 text-xs"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<span>{label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
<Popover
|
||||
|
|
|
|||
|
|
@ -200,12 +200,12 @@ export default function RepoCombobox({
|
|||
>
|
||||
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
|
||||
<CommandInput
|
||||
placeholder="Search repos/folders..."
|
||||
placeholder="Search projects/folders..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No repos/folders match your search.</CommandEmpty>
|
||||
<CommandEmpty>No projects/folders match your search.</CommandEmpty>
|
||||
{filteredRepos.map((repo) => (
|
||||
<CommandItem
|
||||
key={repo.id}
|
||||
|
|
@ -252,7 +252,7 @@ export default function RepoCombobox({
|
|||
className="h-9 w-full justify-start rounded-none px-3 text-xs font-normal"
|
||||
>
|
||||
<FolderPlus className="size-3.5 text-muted-foreground" />
|
||||
<span>{isAdding ? 'Adding folder/repo…' : 'Add folder/repo'}</span>
|
||||
<span>{isAdding ? 'Adding project…' : 'Add project'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Command>
|
||||
|
|
@ -260,7 +260,7 @@ export default function RepoCombobox({
|
|||
</Popover>
|
||||
|
||||
{showStandaloneAddButton ? (
|
||||
/* Why: keep the add-repo action visible even when the repo selector is
|
||||
/* Why: keep the add-project action visible even when the project selector is
|
||||
collapsed so adding a new source stays one click away in the compact composer header. */
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -269,7 +269,7 @@ export default function RepoCombobox({
|
|||
disabled={isAdding}
|
||||
onClick={() => void handleAddFolder()}
|
||||
className="size-9 shrink-0 p-0"
|
||||
aria-label={isAdding ? 'Adding folder or repository' : 'Add folder or repository'}
|
||||
aria-label={isAdding ? 'Adding project' : 'Add project'}
|
||||
>
|
||||
<FolderPlus className="size-3.5" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { MANAGE_SESSIONS_SEARCH_ENTRIES } from './terminal-search'
|
||||
import { splitWorktreeId } from '../../../../shared/worktree-id'
|
||||
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
|
||||
import { useAppStore } from '../../store'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
|
||||
|
|
@ -55,7 +55,7 @@ function formatWorkspace(session: { cwd: string | null; sessionId: string }): st
|
|||
const worktreeId = session.sessionId.slice(0, sep)
|
||||
// Why: take everything after the first `::` to recover the worktree path
|
||||
// from the canonical `${repoId}::${path}` worktreeId encoding.
|
||||
return shortCwd(splitWorktreeId(worktreeId)?.worktreePath ?? worktreeId)
|
||||
return shortCwd(splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? worktreeId)
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,12 +32,12 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
|
|||
return [
|
||||
{
|
||||
title: 'Display Name',
|
||||
description: 'Repo-specific display details for the sidebar and tabs.',
|
||||
keywords: [repo.displayName, repo.path, 'repository name']
|
||||
description: 'Project-specific display details for the sidebar and tabs.',
|
||||
keywords: [repo.displayName, repo.path, 'project name', 'repository name']
|
||||
},
|
||||
{
|
||||
title: 'Badge Color',
|
||||
description: 'Repo color used in the sidebar and tabs.',
|
||||
description: 'Project color used in the sidebar and tabs.',
|
||||
keywords: [repo.displayName, 'color', 'badge']
|
||||
},
|
||||
...(isFolder
|
||||
|
|
@ -64,9 +64,9 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
|
|||
}
|
||||
]),
|
||||
{
|
||||
title: 'Remove Repo',
|
||||
description: 'Remove this repository from Orca.',
|
||||
keywords: [repo.displayName, 'delete', 'repository']
|
||||
title: 'Remove Project',
|
||||
description: 'Remove this project from Orca.',
|
||||
keywords: [repo.displayName, 'delete', 'project', 'repository']
|
||||
},
|
||||
...(isFolder
|
||||
? []
|
||||
|
|
@ -87,7 +87,7 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
|
|||
},
|
||||
{
|
||||
title: 'MCP Configs',
|
||||
description: 'Inspect repo-level MCP server config files.',
|
||||
description: 'Inspect project-level MCP server config files.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'mcp',
|
||||
|
|
@ -215,7 +215,7 @@ export function RepositoryPane({
|
|||
|
||||
const allEntries = getRepositoryPaneSearchEntries(repo)
|
||||
const identityEntries = allEntries.filter((entry) =>
|
||||
['Display Name', 'Badge Color', 'Default Worktree Base', 'Remove Repo'].includes(entry.title)
|
||||
['Display Name', 'Badge Color', 'Default Worktree Base', 'Remove Project'].includes(entry.title)
|
||||
)
|
||||
const sparsePresetEntries = allEntries.filter((entry) =>
|
||||
['Sparse Checkout Presets'].includes(entry.title)
|
||||
|
|
@ -257,7 +257,7 @@ export function RepositoryPane({
|
|||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Identity</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Repo-specific display details for the sidebar and tabs.
|
||||
Project-specific display details for the sidebar and tabs.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Type: <span className="text-foreground">{getRepoKindLabel(repo)}</span>
|
||||
|
|
@ -269,9 +269,9 @@ export function RepositoryPane({
|
|||
) : null}
|
||||
</div>
|
||||
<SearchableSetting
|
||||
title="Remove Repo"
|
||||
description="Remove this repository from Orca."
|
||||
keywords={[repo.displayName, 'delete', 'repository']}
|
||||
title="Remove Project"
|
||||
description="Remove this project from Orca."
|
||||
keywords={[repo.displayName, 'delete', 'project', 'repository']}
|
||||
>
|
||||
<Button
|
||||
variant={confirmingRemove === repo.id ? 'destructive' : 'outline'}
|
||||
|
|
@ -281,15 +281,22 @@ export function RepositoryPane({
|
|||
className="gap-2"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{confirmingRemove === repo.id ? 'Confirm Remove' : 'Remove Repo'}
|
||||
{confirmingRemove === repo.id ? 'Confirm Remove' : 'Remove Project'}
|
||||
</Button>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
|
||||
<SearchableSetting
|
||||
title="Display Name"
|
||||
description="Repo-specific display details for the sidebar and tabs."
|
||||
keywords={[repo.displayName, repo.path, 'repository name', 'color', 'badge']}
|
||||
description="Project-specific display details for the sidebar and tabs."
|
||||
keywords={[
|
||||
repo.displayName,
|
||||
repo.path,
|
||||
'project name',
|
||||
'repository name',
|
||||
'color',
|
||||
'badge'
|
||||
]}
|
||||
className="space-y-2"
|
||||
id={getRepositoryBadgeColorSectionId(repo.id)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1351,7 +1351,7 @@ function Settings(): React.JSX.Element {
|
|||
<SettingsSection
|
||||
key={repo.id}
|
||||
id={repoSectionId}
|
||||
title={`Repo Settings > ${repo.displayName}`}
|
||||
title={`Project Settings > ${repo.displayName}`}
|
||||
description={repo.path}
|
||||
searchEntries={getRepositoryPaneSearchEntries(repo)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export function SettingsSidebar({
|
|||
|
||||
<div className="space-y-2">
|
||||
<p className="px-3 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Repositories
|
||||
Projects
|
||||
</p>
|
||||
|
||||
{repoSections.length > 0 ? (
|
||||
|
|
@ -183,7 +183,7 @@ export function SettingsSidebar({
|
|||
</div>
|
||||
) : (
|
||||
<p className="px-3 text-xs text-muted-foreground">
|
||||
{hasRepos ? 'No matching repository settings.' : 'No repositories added yet.'}
|
||||
{hasRepos ? 'No matching project settings.' : 'No projects added yet.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
|
|
@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => {
|
|||
removeWorktree: vi.fn(),
|
||||
clearWorktreeDeleteState: vi.fn(),
|
||||
allWorktrees: vi.fn<() => Worktree[]>(() => []),
|
||||
repos: [] as Repo[],
|
||||
worktreeLineageById: {} as Record<string, WorktreeLineage>,
|
||||
updateSettings: vi.fn(),
|
||||
openSettingsTarget: vi.fn(),
|
||||
|
|
@ -104,6 +105,7 @@ describe('DeleteWorktreeDialog lineage copy', () => {
|
|||
mocks.state.activeModal = 'delete-worktree'
|
||||
mocks.state.modalData = {}
|
||||
mocks.state.allWorktrees.mockReturnValue([])
|
||||
mocks.state.repos = []
|
||||
mocks.state.worktreeLineageById = {}
|
||||
mocks.state.deleteStateByWorktreeId = {}
|
||||
})
|
||||
|
|
@ -142,4 +144,29 @@ describe('DeleteWorktreeDialog lineage copy', () => {
|
|||
expect(markup).toContain('min-w-0 overflow-hidden')
|
||||
expect(markup).toContain('truncate text-muted-foreground')
|
||||
})
|
||||
|
||||
it('uses non-destructive disk copy for folder workspace deletes', async () => {
|
||||
const workspace = {
|
||||
...makeWorktree('Folder workspace', '/projects/folder'),
|
||||
repoId: 'folder-repo'
|
||||
}
|
||||
mocks.state.modalData = { worktreeId: workspace.id }
|
||||
mocks.state.allWorktrees.mockReturnValue([workspace])
|
||||
mocks.state.repos = [
|
||||
{
|
||||
id: 'folder-repo',
|
||||
path: '/projects/folder',
|
||||
displayName: 'Folder',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
kind: 'folder'
|
||||
}
|
||||
]
|
||||
|
||||
const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog')
|
||||
const markup = renderToStaticMarkup(<DeleteWorktreeDialog />)
|
||||
|
||||
expect(markup).toContain('from Orca. The project folder on disk will not be deleted.')
|
||||
expect(markup).not.toContain('from git and delete its workspace folder.')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ import { toast } from 'sonner'
|
|||
import { runWorktreeDeletesInParallel } from './delete-worktree-flow'
|
||||
import { getWorkspaceDeleteLineage } from './workspace-delete-lineage'
|
||||
import { DeleteWorktreeLineageNotice } from './DeleteWorktreeLineageNotice'
|
||||
import {
|
||||
countFolderWorkspaceDeletes,
|
||||
getDeleteWorktreeDialogCopy,
|
||||
isFolderWorkspaceDelete as getIsFolderWorkspaceDelete
|
||||
} from './delete-worktree-dialog-copy'
|
||||
|
||||
const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
|
|
@ -23,6 +28,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
|||
const removeWorktree = useAppStore((s) => s.removeWorktree)
|
||||
const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState)
|
||||
const allWorktrees = useAppStore((s) => s.allWorktrees)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const worktreeLineageById = useAppStore((s) => s.worktreeLineageById)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
|
|
@ -54,7 +60,20 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
|||
const selected = new Set(worktreeIds)
|
||||
return allWorktrees().filter((item) => selected.has(item.id))
|
||||
}, [allWorktrees, worktreeIds])
|
||||
const repoMap = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos])
|
||||
const isBatchDelete = worktreeIds.length > 1
|
||||
const isFolderWorkspaceDelete = !isBatchDelete && getIsFolderWorkspaceDelete(repoMap, worktree)
|
||||
const folderWorkspaceDeleteCount = useMemo(
|
||||
() => countFolderWorkspaceDeletes(repoMap, worktrees),
|
||||
[repoMap, worktrees]
|
||||
)
|
||||
const deleteCopy = getDeleteWorktreeDialogCopy({
|
||||
isBatchDelete,
|
||||
worktree,
|
||||
worktreeCount: worktrees.length,
|
||||
folderWorkspaceDeleteCount,
|
||||
isFolderWorkspaceDelete
|
||||
})
|
||||
const deleteStateByWorktreeId = useAppStore((s) => s.deleteStateByWorktreeId)
|
||||
const lineageDelete = useMemo(
|
||||
() =>
|
||||
|
|
@ -262,21 +281,8 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
|||
{isBatchDelete ? 'Delete Workspaces' : 'Delete Workspace'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{isBatchDelete ? (
|
||||
<>
|
||||
Remove{' '}
|
||||
<span className="font-medium text-foreground">{worktrees.length} workspaces</span>{' '}
|
||||
from git and delete their workspace folders.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Remove{' '}
|
||||
<span className="break-all font-medium text-foreground">
|
||||
{worktree?.displayName}
|
||||
</span>{' '}
|
||||
from git and delete its workspace folder.
|
||||
</>
|
||||
)}
|
||||
Remove <span className={deleteCopy.targetClassName}>{deleteCopy.targetLabel}</span>{' '}
|
||||
{deleteCopy.descriptionSuffix}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -328,7 +334,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
|||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
This is the <span className="font-semibold">main worktree</span> (the original clone
|
||||
directory). Git does not allow removing the main worktree.
|
||||
directory). {deleteCopy.mainWorktreeBlocker}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ const SidebarFilter = React.memo(function SidebarFilter({
|
|||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<span className="text-[11px] font-semibold tracking-wide uppercase text-muted-foreground">
|
||||
Repositories
|
||||
Projects
|
||||
{hasRepoFilter && (
|
||||
<span className="ml-1.5 normal-case tracking-normal font-medium text-foreground">
|
||||
· {selectedCount}
|
||||
|
|
@ -214,7 +214,7 @@ const SidebarFilter = React.memo(function SidebarFilter({
|
|||
>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
placeholder="Search repos..."
|
||||
placeholder="Search projects..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
|
|
@ -223,7 +223,7 @@ const SidebarFilter = React.memo(function SidebarFilter({
|
|||
iconClassName="h-3.5 w-3.5"
|
||||
/>
|
||||
<CommandList className="max-h-64 py-1">
|
||||
<CommandEmpty className="py-4 text-[11px]">No repos match</CommandEmpty>
|
||||
<CommandEmpty className="py-4 text-[11px]">No projects match</CommandEmpty>
|
||||
{filteredRepos.map((r) => {
|
||||
const checked = selectedRepoIdSet.has(r.id)
|
||||
return (
|
||||
|
|
@ -258,8 +258,8 @@ const SidebarFilter = React.memo(function SidebarFilter({
|
|||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
{/* Why: "Add repo" stays visible regardless of repo count so users
|
||||
can recover from the 0/1-repo state where the repo section is
|
||||
{/* Why: "Add project" stays visible regardless of project count so users
|
||||
can recover from the 0/1-project state where the project section is
|
||||
hidden. Reset sits beside it only when a filter is active. */}
|
||||
<div className="flex items-center justify-between gap-1 px-1 py-1">
|
||||
{hasAnyFilter ? (
|
||||
|
|
@ -279,7 +279,7 @@ const SidebarFilter = React.memo(function SidebarFilter({
|
|||
className="inline-flex items-center gap-1.5 rounded-[5px] px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<FolderPlus className="size-3.5" />
|
||||
Add repo
|
||||
Add project
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { Kanban, Plus } from 'lucide-react'
|
|||
import { useAppStore } from '@/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import SidebarWorkspaceOptionsMenu from './SidebarWorkspaceOptionsMenu'
|
||||
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
|
||||
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
|
|
@ -14,7 +13,9 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
|||
const [workspaceBoardMenuOpen, setWorkspaceBoardMenuOpen] = useState(false)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
|
||||
const groupBy = useAppStore((s) => s.groupBy)
|
||||
const canCreateWorkspace = repos.length > 0
|
||||
const sidebarTitle = groupBy === 'repo' ? 'Projects' : 'Workspaces'
|
||||
|
||||
const handleWorkspaceBoardOpenChange = useCallback((open: boolean) => {
|
||||
setWorkspaceBoardOpen(open)
|
||||
|
|
@ -66,8 +67,8 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
|||
<>
|
||||
<div className="mt-2 flex h-8 items-center justify-between px-2 gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="pl-2 pr-0.5 text-[10.5px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/80 select-none">
|
||||
Workspaces
|
||||
<span className="pl-2 pr-0.5 text-xs font-semibold text-muted-foreground/80 select-none">
|
||||
{sidebarTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
|
|
@ -101,21 +102,21 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
|||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => {
|
||||
if (!canCreateWorktree) {
|
||||
if (!canCreateWorkspace) {
|
||||
return
|
||||
}
|
||||
openModal('new-workspace-composer', { telemetrySource: 'sidebar' })
|
||||
}}
|
||||
aria-label="New workspace"
|
||||
disabled={!canCreateWorktree}
|
||||
disabled={!canCreateWorkspace}
|
||||
>
|
||||
<Plus className="size-3.5" strokeWidth={2.25} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{canCreateWorktree
|
||||
{canCreateWorkspace
|
||||
? `New workspace (${newWorktreeShortcutLabel})`
|
||||
: 'Add a Git project to create worktrees'}
|
||||
: 'Add a project to create workspaces'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const GROUP_BY_OPTIONS = [
|
|||
{ id: 'none', label: 'None' },
|
||||
{ id: 'workspace-status', label: 'Status' },
|
||||
{ id: 'pr-status', label: 'PR' },
|
||||
{ id: 'repo', label: 'Repo' }
|
||||
{ id: 'repo', label: 'Project' }
|
||||
] as const
|
||||
|
||||
const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
|
||||
|
|
@ -49,7 +49,7 @@ const SORT_OPTIONS = [
|
|||
description: 'Agents that need attention, then most recent activity.'
|
||||
},
|
||||
{ id: 'recent', label: 'Recent', description: null },
|
||||
{ id: 'repo', label: 'Repo', description: null },
|
||||
{ id: 'repo', label: 'Project', description: null },
|
||||
{
|
||||
id: 'manual',
|
||||
label: 'Manual',
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export default function WorkspaceKanbanStatusLane({
|
|||
const meta = getWorkspaceStatusVisualMeta(status)
|
||||
const createTooltip = canCreateWorktree
|
||||
? `New workspace in ${status.label}`
|
||||
: 'Add a Git project to create worktrees'
|
||||
: 'Add a project to create workspaces'
|
||||
const createButton = (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types'
|
||||
import type WorktreeCardComponent from './WorktreeCard'
|
||||
|
||||
const fetchHostedReviewForBranch = vi.fn()
|
||||
const fetchIssue = vi.fn()
|
||||
|
|
@ -12,6 +13,7 @@ let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'unread']
|
|||
let tabsByWorktree: Record<string, { id: string }[]> = {}
|
||||
let ptyIdsByTabId: Record<string, string[]> = {}
|
||||
let browserTabsByWorktree: Record<string, { id: string }[]> = {}
|
||||
let WorktreeCard: typeof WorktreeCardComponent
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
|
|
@ -102,6 +104,10 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
|||
}
|
||||
|
||||
describe('WorktreeCard quick actions', () => {
|
||||
beforeAll(async () => {
|
||||
WorktreeCard = (await import('./WorktreeCard')).default
|
||||
}, 20_000)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
worktreeCardProperties = ['status', 'unread']
|
||||
|
|
@ -110,9 +116,7 @@ describe('WorktreeCard quick actions', () => {
|
|||
browserTabsByWorktree = {}
|
||||
})
|
||||
|
||||
it('marks the unread toggle as a workspace-board-preserving action', async () => {
|
||||
const { default: WorktreeCard } = await import('./WorktreeCard')
|
||||
|
||||
it('marks the unread toggle as a workspace-board-preserving action', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
|
||||
)
|
||||
|
|
@ -121,9 +125,7 @@ describe('WorktreeCard quick actions', () => {
|
|||
expect(markup).toContain('data-workspace-board-preserve-open=""')
|
||||
})
|
||||
|
||||
it('shows delete as the top-right quick action for an inactive workspace', async () => {
|
||||
const { default: WorktreeCard } = await import('./WorktreeCard')
|
||||
|
||||
it('shows delete as the top-right quick action for an inactive workspace', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
|
||||
)
|
||||
|
|
@ -131,8 +133,23 @@ describe('WorktreeCard quick actions', () => {
|
|||
expect(markup).toContain('aria-label="Delete workspace"')
|
||||
})
|
||||
|
||||
it('shows sleep as the top-right quick action for a workspace with live activity', async () => {
|
||||
const { default: WorktreeCard } = await import('./WorktreeCard')
|
||||
it('shows delete as the quick action for inactive folder workspace instances', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard
|
||||
worktree={makeWorktree({
|
||||
id: 'repo-1::/repo::workspace:123e4567-e89b-12d3-a456-426614174000',
|
||||
path: '/repo',
|
||||
isMainWorktree: false
|
||||
})}
|
||||
repo={{ ...makeRepo(), kind: 'folder' }}
|
||||
isActive={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('aria-label="Delete workspace"')
|
||||
})
|
||||
|
||||
it('shows sleep as the top-right quick action for a workspace with live activity', () => {
|
||||
const worktree = makeWorktree()
|
||||
tabsByWorktree = { [worktree.id]: [{ id: 'tab-1' }] }
|
||||
ptyIdsByTabId = { 'tab-1': ['pty-1'] }
|
||||
|
|
|
|||
|
|
@ -416,7 +416,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
)
|
||||
const quickActionKind = getWorkspaceQuickActionKind({
|
||||
hasActiveActivity,
|
||||
isDeletable: !worktree.isMainWorktree && !isFolder,
|
||||
isDeletable: !worktree.isMainWorktree,
|
||||
isInactive: !hasActiveActivity,
|
||||
isMacOptionPressed
|
||||
})
|
||||
|
|
@ -629,7 +629,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{isSshDisconnected ? 'SSH disconnected' : 'Remote repository via SSH'}
|
||||
{isSshDisconnected ? 'SSH disconnected' : 'Remote project via SSH'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasSleepableWorkspaceActivity,
|
||||
isContextWorktreeDeletable,
|
||||
shouldUseNativeContextMenu,
|
||||
shouldIgnoreNestedWorktreeContextMenuScope,
|
||||
shouldRemoveFolderProjectFromContextMenu,
|
||||
shouldSuppressContextMenuFollowUpClick
|
||||
} from './WorktreeContextMenu'
|
||||
|
||||
|
|
@ -113,3 +115,19 @@ describe('hasSleepableWorkspaceActivity', () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('folder workspace context deletes', () => {
|
||||
it('routes only the folder root row to project removal', () => {
|
||||
expect(shouldRemoveFolderProjectFromContextMenu(true, { isMainWorktree: true })).toBe(true)
|
||||
expect(shouldRemoveFolderProjectFromContextMenu(true, { isMainWorktree: false })).toBe(false)
|
||||
expect(shouldRemoveFolderProjectFromContextMenu(false, { isMainWorktree: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('treats additional folder workspace rows as deletable workspace rows', () => {
|
||||
const folderRepo = { kind: 'folder' as const }
|
||||
|
||||
expect(isContextWorktreeDeletable({ isMainWorktree: false }, folderRepo)).toBe(true)
|
||||
expect(isContextWorktreeDeletable({ isMainWorktree: true }, folderRepo)).toBe(false)
|
||||
expect(isContextWorktreeDeletable({ isMainWorktree: false }, null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import {
|
|||
import { useAppStore } from '@/store'
|
||||
import { useRepoById, useRepoMap, useWorktreeMap } from '@/store/selectors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow'
|
||||
import { runSleepWorktrees } from './sleep-worktree-flow'
|
||||
|
|
@ -101,6 +101,20 @@ function hasSleepableWorkspaceActivity(
|
|||
return hasLiveTerminal || hasBrowser
|
||||
}
|
||||
|
||||
function shouldRemoveFolderProjectFromContextMenu(
|
||||
isFolder: boolean,
|
||||
worktree: Pick<Worktree, 'isMainWorktree'>
|
||||
): boolean {
|
||||
return isFolder && worktree.isMainWorktree
|
||||
}
|
||||
|
||||
function isContextWorktreeDeletable(
|
||||
worktree: Pick<Worktree, 'isMainWorktree'>,
|
||||
repo: Pick<Repo, 'kind'> | null | undefined
|
||||
): boolean {
|
||||
return repo != null && !worktree.isMainWorktree
|
||||
}
|
||||
|
||||
function findSidebarVirtualRowByKey(sidebar: Element, rowKey: string): HTMLElement | null {
|
||||
return (
|
||||
Array.from(sidebar.querySelectorAll<HTMLElement>('[data-worktree-virtual-row]')).find(
|
||||
|
|
@ -218,10 +232,11 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
() =>
|
||||
activeContextWorktrees.filter((item) => {
|
||||
const itemRepo = repoMap.get(item.repoId)
|
||||
return !item.isMainWorktree && itemRepo != null && !isFolderRepo(itemRepo)
|
||||
return isContextWorktreeDeletable(item, itemRepo)
|
||||
}),
|
||||
[activeContextWorktrees, repoMap]
|
||||
)
|
||||
const removesFolderProject = shouldRemoveFolderProjectFromContextMenu(isFolder, worktree)
|
||||
const sleepLabel =
|
||||
isMultiContext && sleepableWorktrees.length > 0
|
||||
? `Sleep ${sleepableWorktrees.length} Workspace${sleepableWorktrees.length === 1 ? '' : 's'}`
|
||||
|
|
@ -325,10 +340,10 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
restoreSidebarPosition()
|
||||
return
|
||||
}
|
||||
if (isFolder) {
|
||||
// Why: folder mode reuses the worktree row UI for a synthetic root entry,
|
||||
if (removesFolderProject) {
|
||||
// Why: folder mode reuses the worktree row UI for the root entry,
|
||||
// but users still expect "remove" to disconnect the folder from Orca,
|
||||
// not to run git-style delete semantics against the real folder on disk.
|
||||
// not to delete the selected logical workspace metadata.
|
||||
openModal('confirm-remove-folder', {
|
||||
repoId: worktree.repoId,
|
||||
displayName: worktree.displayName
|
||||
|
|
@ -345,9 +360,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
}, 50)
|
||||
}, [
|
||||
batchDeleteWorktrees,
|
||||
isFolder,
|
||||
isMultiContext,
|
||||
openModal,
|
||||
removesFolderProject,
|
||||
setMenuOpenState,
|
||||
worktree.displayName,
|
||||
worktree.id,
|
||||
|
|
@ -565,7 +580,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
? 'Deleting…'
|
||||
: isMultiContext
|
||||
? deleteLabel
|
||||
: isFolder
|
||||
: removesFolderProject
|
||||
? 'Remove Folder from Orca'
|
||||
: 'Delete'}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -581,6 +596,8 @@ export {
|
|||
WORKTREE_CONTEXT_MENU_SCOPE_ATTR,
|
||||
WORKTREE_NATIVE_CONTEXT_MENU_ATTR,
|
||||
hasSleepableWorkspaceActivity,
|
||||
isContextWorktreeDeletable,
|
||||
shouldRemoveFolderProjectFromContextMenu,
|
||||
shouldUseNativeContextMenu,
|
||||
shouldSuppressContextMenuFollowUpClick,
|
||||
shouldIgnoreNestedWorktreeContextMenuScope
|
||||
|
|
|
|||
|
|
@ -1966,7 +1966,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
}}
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Repo Settings
|
||||
Project Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
|
|
@ -1979,7 +1979,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
}}
|
||||
>
|
||||
<Palette className="size-3.5" />
|
||||
Change Repo Color
|
||||
Change Project Color
|
||||
</DropdownMenuItem>
|
||||
{row.repo && isGitRepoKind(row.repo) ? (
|
||||
<DropdownMenuItem
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
|
||||
type WorktreeRepoRef = Pick<Worktree, 'repoId'>
|
||||
|
||||
export function isFolderWorkspaceDelete(
|
||||
repoMap: ReadonlyMap<string, Repo>,
|
||||
worktree: WorktreeRepoRef | null | undefined
|
||||
): boolean {
|
||||
if (!worktree) {
|
||||
return false
|
||||
}
|
||||
const repo = repoMap.get(worktree.repoId)
|
||||
return repo ? isFolderRepo(repo) : false
|
||||
}
|
||||
|
||||
export function countFolderWorkspaceDeletes(
|
||||
repoMap: ReadonlyMap<string, Repo>,
|
||||
worktrees: readonly WorktreeRepoRef[]
|
||||
): number {
|
||||
return worktrees.filter((item) => isFolderWorkspaceDelete(repoMap, item)).length
|
||||
}
|
||||
|
||||
export function getDeleteWorktreeDialogCopy(args: {
|
||||
isBatchDelete: boolean
|
||||
worktree: Pick<Worktree, 'displayName'> | null
|
||||
worktreeCount: number
|
||||
folderWorkspaceDeleteCount: number
|
||||
isFolderWorkspaceDelete: boolean
|
||||
}): {
|
||||
targetLabel: string | undefined
|
||||
targetClassName: string
|
||||
descriptionSuffix: string
|
||||
mainWorktreeBlocker: string
|
||||
} {
|
||||
const allFolderWorkspaceDeletes =
|
||||
args.isBatchDelete &&
|
||||
args.worktreeCount > 0 &&
|
||||
args.folderWorkspaceDeleteCount === args.worktreeCount
|
||||
const mixedFolderWorkspaceDeletes =
|
||||
args.isBatchDelete &&
|
||||
args.folderWorkspaceDeleteCount > 0 &&
|
||||
args.folderWorkspaceDeleteCount < args.worktreeCount
|
||||
return {
|
||||
targetLabel: args.isBatchDelete
|
||||
? `${args.worktreeCount} workspaces`
|
||||
: args.worktree?.displayName,
|
||||
targetClassName: args.isBatchDelete
|
||||
? 'font-medium text-foreground'
|
||||
: 'break-all font-medium text-foreground',
|
||||
descriptionSuffix: args.isBatchDelete
|
||||
? allFolderWorkspaceDeletes
|
||||
? 'from Orca. Project folders on disk will not be deleted.'
|
||||
: mixedFolderWorkspaceDeletes
|
||||
? 'from Orca. Git worktrees will also be removed from git and disk; folder workspaces will only remove the Orca workspace entry.'
|
||||
: 'from git and delete their workspace folders.'
|
||||
: args.isFolderWorkspaceDelete
|
||||
? 'from Orca. The project folder on disk will not be deleted.'
|
||||
: 'from git and delete its workspace folder.',
|
||||
mainWorktreeBlocker: args.isFolderWorkspaceDelete
|
||||
? 'Remove the folder project instead of deleting this workspace.'
|
||||
: 'Git does not allow removing the main worktree.'
|
||||
}
|
||||
}
|
||||
|
|
@ -166,13 +166,10 @@ export function runWorktreeDeleteWithToast(
|
|||
* running the delete immediately with toast feedback, or opening the
|
||||
* confirmation modal.
|
||||
*
|
||||
* Why folder mode is handled at the call site: folder-repo removal branches
|
||||
* to a different modal (`confirm-remove-folder`) and the folder-vs-git
|
||||
* determination requires the full Worktree record's repoId. Keeping that
|
||||
* decision adjacent to the caller (rather than branching inside this helper)
|
||||
* avoids bleeding folder-mode concerns into what is otherwise a simple
|
||||
* skip-confirm-vs-modal decision, and lets the context menu short-circuit
|
||||
* before ever entering this funnel.
|
||||
* Why folder-root removal is handled at the call site: disconnecting the
|
||||
* folder project branches to a different modal (`confirm-remove-folder`).
|
||||
* Keeping that decision adjacent to the caller avoids mixing project removal
|
||||
* into what is otherwise a workspace delete confirmation flow.
|
||||
*
|
||||
* The main-worktree / missing-record guard here is defense-in-depth — the
|
||||
* caller is responsible for disabling UI when this is known ahead of time,
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ describe('repo header create state', () => {
|
|||
})
|
||||
).toEqual({
|
||||
disabled: false,
|
||||
tooltip: 'Create workspace for orca',
|
||||
ariaLabel: 'Create workspace for orca',
|
||||
tooltip: 'Create new worktree for orca',
|
||||
ariaLabel: 'Create new worktree for orca',
|
||||
requiresSshReconnect: false
|
||||
})
|
||||
})
|
||||
|
||||
it('disables folder repos', () => {
|
||||
it('allows folder repos as workspace creates', () => {
|
||||
expect(
|
||||
getRepoHeaderCreateState({
|
||||
repo: makeRepo({ kind: 'folder' }),
|
||||
|
|
@ -38,8 +38,8 @@ describe('repo header create state', () => {
|
|||
sshStatus: null
|
||||
})
|
||||
).toMatchObject({
|
||||
disabled: true,
|
||||
tooltip: 'docs is opened as a folder',
|
||||
disabled: false,
|
||||
tooltip: 'Create workspace for docs',
|
||||
requiresSshReconnect: false
|
||||
})
|
||||
})
|
||||
|
|
@ -53,7 +53,7 @@ describe('repo header create state', () => {
|
|||
})
|
||||
).toMatchObject({
|
||||
disabled: false,
|
||||
tooltip: 'Create workspace for remote',
|
||||
tooltip: 'Create new worktree for remote',
|
||||
requiresSshReconnect: false
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ export function getRepoHeaderCreateState(input: {
|
|||
}): RepoHeaderCreateState {
|
||||
if (!isGitRepoKind(input.repo)) {
|
||||
return {
|
||||
disabled: true,
|
||||
tooltip: `${input.label} is opened as a folder`,
|
||||
ariaLabel: `${input.label} is opened as a folder`,
|
||||
disabled: false,
|
||||
tooltip: `Create workspace for ${input.label}`,
|
||||
ariaLabel: `Create workspace for ${input.label}`,
|
||||
requiresSshReconnect: false
|
||||
}
|
||||
}
|
||||
|
|
@ -39,8 +39,8 @@ export function getRepoHeaderCreateState(input: {
|
|||
|
||||
return {
|
||||
disabled: false,
|
||||
tooltip: `Create workspace for ${input.label}`,
|
||||
ariaLabel: `Create workspace for ${input.label}`,
|
||||
tooltip: `Create new worktree for ${input.label}`,
|
||||
ariaLabel: `Create new worktree for ${input.label}`,
|
||||
requiresSshReconnect: false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import type { WorkspaceStatus } from '../../../../shared/types'
|
||||
|
||||
export function useWorkspaceKanbanCreateWorktree(): {
|
||||
|
|
@ -8,7 +7,7 @@ export function useWorkspaceKanbanCreateWorktree(): {
|
|||
createWorktreeForStatus: (workspaceStatus: WorkspaceStatus) => void
|
||||
} {
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const canCreateWorktree = useAppStore((s) => s.repos.some((repo) => isGitRepoKind(repo)))
|
||||
const canCreateWorktree = useAppStore((s) => s.repos.length > 0)
|
||||
|
||||
const createWorktreeForStatus = useCallback(
|
||||
(workspaceStatus: WorkspaceStatus) => {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ import type {
|
|||
} from '../../../../shared/types'
|
||||
import { parsePtySessionId } from '../../../../shared/pty-session-id-format'
|
||||
import { parsePaneKey as parseStablePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id'
|
||||
import {
|
||||
getRepoIdFromWorktreeId,
|
||||
getWorktreePathBasenameFromId
|
||||
} from '../../../../shared/worktree-id'
|
||||
|
||||
// ─── View-model types (renderer-local) ──────────────────────────────
|
||||
|
||||
|
|
@ -111,16 +114,7 @@ function deriveRepoIdFromWorktreeId(worktreeId: string): string {
|
|||
}
|
||||
|
||||
function deriveWorktreeNameFromWorktreeId(worktreeId: string): string {
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
if (!parsed) {
|
||||
return worktreeId
|
||||
}
|
||||
const path = parsed.worktreePath
|
||||
if (!path) {
|
||||
return worktreeId
|
||||
}
|
||||
const parts = path.split(/[\\/]+/).filter(Boolean)
|
||||
return parts.at(-1) ?? worktreeId
|
||||
return getWorktreePathBasenameFromId(worktreeId) ?? worktreeId
|
||||
}
|
||||
|
||||
function shortCwd(cwd: string): string {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ type RepoMultiComboboxProps = {
|
|||
* `null` is never emitted here — persistence of "sticky-all" (selection
|
||||
* equals every eligible repo) is the caller's responsibility. */
|
||||
onChange: (next: ReadonlySet<string>) => void
|
||||
/** Clicking the sticky "All repos" row emits a full-set selection AND this
|
||||
/** Clicking the sticky "All projects" row emits a full-set selection AND this
|
||||
* signal, so the caller can persist `null` (sticky-all) rather than a
|
||||
* frozen snapshot that would exclude repos added later. */
|
||||
onSelectAll: () => void
|
||||
|
|
@ -32,10 +32,10 @@ type RepoMultiComboboxProps = {
|
|||
|
||||
function renderTriggerLabel(repos: Repo[], selected: ReadonlySet<string>): React.JSX.Element {
|
||||
if (repos.length === 0) {
|
||||
return <span className="text-muted-foreground">No repos</span>
|
||||
return <span className="text-muted-foreground">No projects</span>
|
||||
}
|
||||
if (selected.size === repos.length) {
|
||||
return <span className="inline-flex min-w-0 items-center gap-1.5">All repos</span>
|
||||
return <span className="inline-flex min-w-0 items-center gap-1.5">All projects</span>
|
||||
}
|
||||
const selectedRepos = repos.filter((r) => selected.has(r.id))
|
||||
const [first, second, ...rest] = selectedRepos
|
||||
|
|
@ -92,7 +92,7 @@ export default function RepoMultiCombobox({
|
|||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (allSelected) {
|
||||
// Why: toggle — clicking "All repos" while everything is selected
|
||||
// Why: toggle — clicking "All projects" while everything is selected
|
||||
// collapses to a single repo. The fetch effect requires at least one
|
||||
// selection, so we keep the first eligible repo instead of emitting
|
||||
// an empty set.
|
||||
|
|
@ -120,7 +120,7 @@ export default function RepoMultiCombobox({
|
|||
<ChevronsUpDown className="size-3.5 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{/* Why: trigger width can be as narrow as the "All repos" label, but the
|
||||
{/* Why: trigger width can be as narrow as the "All projects" label, but the
|
||||
popover hosts a search input and repo rows with paths. Use the
|
||||
trigger as a minimum width and let the content expand to a readable
|
||||
size so the search field and repo names aren't truncated. */}
|
||||
|
|
@ -131,12 +131,12 @@ export default function RepoMultiCombobox({
|
|||
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
placeholder="Search repos..."
|
||||
placeholder="Search projects..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
className="text-xs"
|
||||
/>
|
||||
{/* Why: sticky "All repos" row sits above the CommandList so it
|
||||
{/* Why: sticky "All projects" row sits above the CommandList so it
|
||||
stays visible while the user scrolls a long repo list. Selecting
|
||||
it emits `onSelectAll` (not a snapshot via onChange) so the
|
||||
caller can persist sticky-all semantics. */}
|
||||
|
|
@ -157,11 +157,11 @@ export default function RepoMultiCombobox({
|
|||
allSelected ? 'opacity-70' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span>All repos</span>
|
||||
<span>All projects</span>
|
||||
</button>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandEmpty>No repos match your search.</CommandEmpty>
|
||||
<CommandEmpty>No projects match your search.</CommandEmpty>
|
||||
{filteredRepos.map((repo) => {
|
||||
const isSelected = selected.has(repo.id)
|
||||
const isLastSelected = isSelected && selected.size <= 1
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export type UseComposerStateOptions = {
|
|||
export type ComposerCardProps = {
|
||||
eligibleRepos: ReturnType<typeof useAppStore.getState>['repos']
|
||||
repoId: string
|
||||
selectedRepoIsGit: boolean
|
||||
onRepoChange: (value: string) => void
|
||||
name: string
|
||||
onNameValueChange: (value: string) => void
|
||||
|
|
@ -288,7 +289,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
|
||||
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
|
||||
const eligibleRepos = useMemo(() => repos.filter((repo) => Boolean(repo.path)), [repos])
|
||||
const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null
|
||||
const resolvedInitialWorkspaceStatus = useMemo(
|
||||
() =>
|
||||
|
|
@ -310,6 +311,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const [internalRepoId, setInternalRepoId] = useState<string>(resolvedInitialRepoId)
|
||||
const repoId = repoIdOverride ?? internalRepoId
|
||||
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
|
||||
const selectedRepoIsGit = selectedRepo ? isGitRepoKind(selectedRepo) : false
|
||||
const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
|
||||
const selectedRepoSshState = selectedRepoConnectionId
|
||||
? (sshConnectionStates.get(selectedRepoConnectionId) ?? null)
|
||||
|
|
@ -512,7 +514,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
[]
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!selectedRepo || !selectedRepoPath) {
|
||||
if (!selectedRepo || !selectedRepoPath || !selectedRepoIsGit) {
|
||||
setSelectedRepoSlug(null)
|
||||
return
|
||||
}
|
||||
|
|
@ -537,7 +539,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [repoId, selectedRepo, selectedRepoPath])
|
||||
}, [repoId, selectedRepo, selectedRepoIsGit, selectedRepoPath])
|
||||
const sparsePresetsForRepo = sparsePresetsByRepo[repoId]
|
||||
const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS
|
||||
const normalizedSparseDirectories = useMemo(
|
||||
|
|
@ -565,6 +567,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
if (!sparseEnabled) {
|
||||
return null
|
||||
}
|
||||
if (!selectedRepoIsGit) {
|
||||
return null
|
||||
}
|
||||
if (selectedRepo?.connectionId) {
|
||||
return 'Sparse checkout is only supported for local repos right now.'
|
||||
}
|
||||
|
|
@ -577,7 +582,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
return 'Use repo-relative directories, not root or parent paths.'
|
||||
}
|
||||
return null
|
||||
}, [normalizedSparseDirectories, selectedRepo?.connectionId, sparseEnabled])
|
||||
}, [normalizedSparseDirectories, selectedRepo?.connectionId, selectedRepoIsGit, sparseEnabled])
|
||||
const parsedLinkedIssueNumber = useMemo(
|
||||
() => (linkedIssue.trim() ? parseGitHubIssueOrPRNumber(linkedIssue) : null),
|
||||
[linkedIssue]
|
||||
|
|
@ -608,8 +613,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
return null
|
||||
}, [linkedPR, name, selectedRepoSlug])
|
||||
const setupConfig = useMemo(
|
||||
() => getSetupConfig(selectedRepo, yamlHooks),
|
||||
[selectedRepo, yamlHooks]
|
||||
() => (selectedRepoIsGit ? getSetupConfig(selectedRepo, yamlHooks) : null),
|
||||
[selectedRepo, selectedRepoIsGit, yamlHooks]
|
||||
)
|
||||
const setupPolicy: SetupRunPolicy = selectedRepo?.hookSettings?.setupRunPolicy ?? 'run-by-default'
|
||||
const hasIssueAutomationConfig = enableIssueAutomation && issueCommandTemplate.length > 0
|
||||
|
|
@ -633,7 +638,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
? 'run'
|
||||
: 'skip')
|
||||
const isSetupCheckPending = Boolean(repoId) && checkedHooksRepoId !== repoId
|
||||
const shouldWaitForSetupCheck = Boolean(selectedRepo) && isSetupCheckPending
|
||||
const shouldWaitForSetupCheck = Boolean(selectedRepo) && selectedRepoIsGit && isSetupCheckPending
|
||||
|
||||
// Why: when the user leaves the workspace name blank and provides no other
|
||||
// seed source (prompt, linked issue/PR), pick a repo-scoped unique marine
|
||||
|
|
@ -765,14 +770,20 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
// Why: the compact sparse dropdown is always visible under Advanced, so
|
||||
// presets must load before sparse mode is enabled.
|
||||
useEffect(() => {
|
||||
if (!repoId || selectedRepo?.connectionId) {
|
||||
if (!repoId || !selectedRepoIsGit || selectedRepo?.connectionId) {
|
||||
return
|
||||
}
|
||||
if (sparsePresetsByRepo[repoId] !== undefined) {
|
||||
return
|
||||
}
|
||||
void fetchSparsePresets(repoId)
|
||||
}, [fetchSparsePresets, repoId, selectedRepo?.connectionId, sparsePresetsByRepo])
|
||||
}, [
|
||||
fetchSparsePresets,
|
||||
repoId,
|
||||
selectedRepo?.connectionId,
|
||||
selectedRepoIsGit,
|
||||
sparsePresetsByRepo
|
||||
])
|
||||
|
||||
// Why: detect agents for the selected repo. For local repos this runs once
|
||||
// on mount (deduped by the store). For remote repos it re-runs when the
|
||||
|
|
@ -815,6 +826,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
setYamlHooks(null)
|
||||
setCheckedHooksRepoId(null)
|
||||
|
||||
if (!selectedRepoIsGit) {
|
||||
setHasLoadedIssueCommand(true)
|
||||
setCheckedHooksRepoId(repoId)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
void loadHookCheckForRepo(repoId)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
|
|
@ -851,7 +870,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [commitHookCheckIfCurrent, enableIssueAutomation, loadHookCheckForRepo, repoId, settings])
|
||||
}, [
|
||||
commitHookCheckIfCurrent,
|
||||
enableIssueAutomation,
|
||||
loadHookCheckForRepo,
|
||||
repoId,
|
||||
selectedRepoIsGit,
|
||||
settings
|
||||
])
|
||||
|
||||
const onConnectSelectedRepo = useCallback(async (): Promise<void> => {
|
||||
const targetId = selectedRepoConnectionIdRef.current
|
||||
|
|
@ -871,7 +897,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
try {
|
||||
await window.api.ssh.connect({ targetId })
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to connect to repository.')
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to connect to project.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -885,7 +911,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const prefetchSshConnectedGeneration =
|
||||
selectedRepoConnectionId && selectedRepoSshStatus === 'connected' ? sshConnectedGeneration : 0
|
||||
useEffect(() => {
|
||||
if (!selectedRepo?.path || !canPrefetchSelectedRepoWorkItems) {
|
||||
if (!selectedRepoIsGit || !selectedRepo?.path || !canPrefetchSelectedRepoWorkItems) {
|
||||
return
|
||||
}
|
||||
prefetchWorkItems(selectedRepo.id, selectedRepo.path, PER_REPO_FETCH_LIMIT, 'is:pr is:open')
|
||||
|
|
@ -894,7 +920,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
prefetchSshConnectedGeneration,
|
||||
prefetchWorkItems,
|
||||
selectedRepo?.id,
|
||||
selectedRepo?.path
|
||||
selectedRepo?.path,
|
||||
selectedRepoIsGit
|
||||
])
|
||||
|
||||
// Reset setup decision when config / policy changes.
|
||||
|
|
@ -921,7 +948,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
}, [linkQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkPopoverOpen || !selectedRepo) {
|
||||
if (!linkPopoverOpen || !selectedRepo || !selectedRepoIsGit) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -977,10 +1004,15 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [linkPopoverOpen, selectedRepo])
|
||||
}, [linkPopoverOpen, selectedRepo, selectedRepoIsGit])
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkPopoverOpen || !selectedRepo || normalizedLinkQuery.directNumber === null) {
|
||||
if (
|
||||
!linkPopoverOpen ||
|
||||
!selectedRepo ||
|
||||
!selectedRepoIsGit ||
|
||||
normalizedLinkQuery.directNumber === null
|
||||
) {
|
||||
setLinkDirectItem(null)
|
||||
setLinkDirectLoading(false)
|
||||
return
|
||||
|
|
@ -1020,7 +1052,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [linkPopoverOpen, normalizedLinkQuery.directNumber, selectedRepo])
|
||||
}, [linkPopoverOpen, normalizedLinkQuery.directNumber, selectedRepo, selectedRepoIsGit])
|
||||
|
||||
const applyLinkedWorkItem = useCallback(
|
||||
(item: GitHubWorkItem): void => {
|
||||
|
|
@ -1207,7 +1239,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
return null
|
||||
}
|
||||
if (!targetRepoPath) {
|
||||
toast.error('No remote repository path is available for attachments.')
|
||||
toast.error('No remote project path is available for attachments.')
|
||||
return { filePaths: [], folderPaths: [] }
|
||||
}
|
||||
const destinationDir = joinPath(targetRepoPath, '.orca/drops')
|
||||
|
|
@ -1694,14 +1726,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
setCreateError(null)
|
||||
setCreating(true)
|
||||
try {
|
||||
const setupTrustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
|
||||
const setupTrustDecision = selectedRepoIsGit
|
||||
? await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
|
||||
: 'skip'
|
||||
const effectiveSetupDecision: SetupDecision =
|
||||
setupTrustDecision === 'skip'
|
||||
? 'skip'
|
||||
: ((resolvedSetupDecision ?? 'inherit') as SetupDecision)
|
||||
|
||||
let issueCommandTrustDecision: 'run' | 'skip' = 'run'
|
||||
if (shouldRunIssueAutomation) {
|
||||
if (selectedRepoIsGit && shouldRunIssueAutomation) {
|
||||
issueCommandTrustDecision =
|
||||
setupTrustDecision === 'skip'
|
||||
? 'skip'
|
||||
|
|
@ -1716,9 +1750,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const result = await createWorktree(
|
||||
repoId,
|
||||
workspaceName,
|
||||
baseBranch,
|
||||
selectedRepoIsGit ? baseBranch : undefined,
|
||||
effectiveSetupDecision,
|
||||
sparseEnabled
|
||||
selectedRepoIsGit && sparseEnabled
|
||||
? {
|
||||
directories: normalizedSparseDirectories,
|
||||
...(effectivePresetId ? { presetId: effectivePresetId } : {})
|
||||
|
|
@ -1832,6 +1866,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
resolvedSetupDecision,
|
||||
resolvedInitialWorkspaceStatus,
|
||||
selectedRepo,
|
||||
selectedRepoIsGit,
|
||||
selectedRepoRequiresConnection,
|
||||
settings?.agentCmdOverrides,
|
||||
settings?.rightSidebarOpenByDefault,
|
||||
|
|
@ -1876,7 +1911,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
try {
|
||||
let submitSetupConfig = setupConfig
|
||||
let submitResolvedSetupDecision = resolvedSetupDecision
|
||||
if (checkedHooksRepoId !== repoId) {
|
||||
if (selectedRepoIsGit && checkedHooksRepoId !== repoId) {
|
||||
let hookCheck: HookCheckResult
|
||||
try {
|
||||
hookCheck = await loadHookCheckForRepo(repoId)
|
||||
|
|
@ -1895,12 +1930,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
? 'run'
|
||||
: 'skip')
|
||||
}
|
||||
if (submitSetupConfig && setupPolicy === 'ask' && !setupDecision) {
|
||||
if (selectedRepoIsGit && submitSetupConfig && setupPolicy === 'ask' && !setupDecision) {
|
||||
setAdvancedOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
|
||||
const trustDecision = selectedRepoIsGit
|
||||
? await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
|
||||
: 'skip'
|
||||
const effectiveSetupDecision: SetupDecision =
|
||||
trustDecision === 'skip'
|
||||
? 'skip'
|
||||
|
|
@ -1914,9 +1951,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const result = await createWorktree(
|
||||
repoId,
|
||||
workspaceName,
|
||||
baseBranch,
|
||||
selectedRepoIsGit ? baseBranch : undefined,
|
||||
effectiveSetupDecision,
|
||||
sparseEnabled
|
||||
selectedRepoIsGit && sparseEnabled
|
||||
? {
|
||||
directories: normalizedSparseDirectories,
|
||||
...(effectivePresetId ? { presetId: effectivePresetId } : {})
|
||||
|
|
@ -2073,6 +2110,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
resolvedSetupDecision,
|
||||
resolvedInitialWorkspaceStatus,
|
||||
selectedRepo,
|
||||
selectedRepoIsGit,
|
||||
selectedRepoRequiresConnection,
|
||||
settings?.agentCmdOverrides,
|
||||
settings?.rightSidebarOpenByDefault,
|
||||
|
|
@ -2112,6 +2150,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const cardProps: ComposerCardProps = {
|
||||
eligibleRepos,
|
||||
repoId,
|
||||
selectedRepoIsGit,
|
||||
onRepoChange: handleRepoChange,
|
||||
name,
|
||||
onNameValueChange: handleNameValueChange,
|
||||
|
|
@ -2174,7 +2213,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
shouldWaitForSetupCheck,
|
||||
resolvedSetupDecision,
|
||||
createError,
|
||||
canUseSparseCheckout: !selectedRepo?.connectionId,
|
||||
canUseSparseCheckout: selectedRepoIsGit && !selectedRepo?.connectionId,
|
||||
sparsePresets,
|
||||
sparseSelectedPresetId,
|
||||
onSparseSelectPreset: handleSparseSelectPreset
|
||||
|
|
|
|||
|
|
@ -705,11 +705,10 @@ export function useIpcEvents(): void {
|
|||
|
||||
unsubs.push(
|
||||
window.api.ui.onOpenNewWorkspace(() => {
|
||||
// Why: mirror the renderer's App.tsx Cmd+N guard — only open the
|
||||
// composer when there is at least one real git repo configured, so
|
||||
// users on a fresh install don't get a modal with nothing to target.
|
||||
// Why: keep the global shortcut quiet on a fresh install, but allow
|
||||
// both Git projects and plain folder projects to create workspaces.
|
||||
const store = useAppStore.getState()
|
||||
if (!store.repos.some((repo) => isGitRepoKind(repo))) {
|
||||
if (store.repos.length === 0) {
|
||||
return
|
||||
}
|
||||
if (store.activeModal === 'new-workspace-composer') {
|
||||
|
|
|
|||
|
|
@ -1056,7 +1056,7 @@ export type GitHubSlice = {
|
|||
* Why: fan out a single work-item query across multiple repos. Partial
|
||||
* failures don't reject — a repo that both fails to fetch *and* has no
|
||||
* cached fallback contributes nothing and increments `failedCount`, which
|
||||
* the caller surfaces as a "N of M repos failed to load" banner. A repo
|
||||
* the caller surfaces as a "N of M projects failed to load" banner. A repo
|
||||
* served from stale cache on rejection is NOT counted as failed — matching
|
||||
* the single-repo behavior of quietly serving stale data.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import {
|
|||
WORKTREE_ID_SEPARATOR,
|
||||
getRepoIdFromWorktreeId,
|
||||
getWorktreePathBasenameFromId,
|
||||
splitWorktreeId
|
||||
splitWorktreeId,
|
||||
splitWorktreeIdForFilesystem
|
||||
} from './worktree-id'
|
||||
|
||||
describe('WORKTREE_ID_SEPARATOR', () => {
|
||||
|
|
@ -73,6 +74,23 @@ describe('splitWorktreeId', () => {
|
|||
it('splits on the first separator when the path itself contains "::"', () => {
|
||||
expect(splitWorktreeId('repo::a::b')).toEqual({ repoId: 'repo', worktreePath: 'a::b' })
|
||||
})
|
||||
|
||||
it('preserves folder workspace instance suffixes in the literal parsed path', () => {
|
||||
expect(
|
||||
splitWorktreeId('repo::/folder::workspace:123e4567-e89b-12d3-a456-426614174000')
|
||||
).toEqual({
|
||||
repoId: 'repo',
|
||||
worktreePath: '/folder::workspace:123e4567-e89b-12d3-a456-426614174000'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitWorktreeIdForFilesystem', () => {
|
||||
it('strips folder workspace instance suffixes from the parsed path', () => {
|
||||
expect(
|
||||
splitWorktreeIdForFilesystem('repo::/folder::workspace:123e4567-e89b-12d3-a456-426614174000')
|
||||
).toEqual({ repoId: 'repo', worktreePath: '/folder' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorktreePathBasenameFromId', () => {
|
||||
|
|
@ -88,6 +106,14 @@ describe('getWorktreePathBasenameFromId', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('returns the real folder basename for folder workspace instance ids', () => {
|
||||
expect(
|
||||
getWorktreePathBasenameFromId(
|
||||
'repo-123::/abs/project::workspace:123e4567-e89b-12d3-a456-426614174000'
|
||||
)
|
||||
).toBe('project')
|
||||
})
|
||||
|
||||
it('returns null when no worktree path is available', () => {
|
||||
expect(getWorktreePathBasenameFromId('repo-123')).toBeNull()
|
||||
expect(getWorktreePathBasenameFromId('repo-123::')).toBeNull()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ export type ParsedWorktreeId = {
|
|||
worktreePath: string
|
||||
}
|
||||
|
||||
export const FOLDER_WORKSPACE_INSTANCE_SEPARATOR = '::workspace:'
|
||||
const FOLDER_WORKSPACE_INSTANCE_SUFFIX = new RegExp(
|
||||
`${FOLDER_WORKSPACE_INSTANCE_SEPARATOR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[0-9a-f-]{36}$`
|
||||
)
|
||||
|
||||
export function getRepoIdFromWorktreeId(worktreeId: string): string {
|
||||
const separatorIdx = worktreeId.indexOf(WORKTREE_ID_SEPARATOR)
|
||||
return separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx)
|
||||
|
|
@ -23,8 +28,22 @@ export function splitWorktreeId(worktreeId: string): ParsedWorktreeId | null {
|
|||
}
|
||||
}
|
||||
|
||||
export function getWorktreePathBasenameFromId(worktreeId: string): string | null {
|
||||
export function splitWorktreeIdForFilesystem(worktreeId: string): ParsedWorktreeId | null {
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
repoId: parsed.repoId,
|
||||
// Why: folder projects can have multiple workspace sessions backed by the
|
||||
// same directory. Their IDs carry a UUID suffix, but filesystem callers
|
||||
// still need the real folder path as cwd/root.
|
||||
worktreePath: parsed.worktreePath.replace(FOLDER_WORKSPACE_INSTANCE_SUFFIX, '')
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorktreePathBasenameFromId(worktreeId: string): string | null {
|
||||
const parsed = splitWorktreeIdForFilesystem(worktreeId)
|
||||
const normalizedPath = parsed?.worktreePath.trim().replace(/[\\/]+$/g, '') ?? ''
|
||||
if (!normalizedPath) {
|
||||
return null
|
||||
|
|
|
|||
Loading…
Reference in New Issue