Add multi-repo folder workspaces (v1) (#5172)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
7fbb3003f2
commit
91e354ccb1
|
|
@ -47,6 +47,8 @@ import {
|
|||
|
||||
const REPO_ID = 'repo1'
|
||||
const WORKTREE_ID = `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/wt`
|
||||
const FOLDER_WORKSPACE_ID = 'folder-workspace-1'
|
||||
const FOLDER_WORKTREE_ID = `folder:${FOLDER_WORKSPACE_ID}`
|
||||
const TAB_ID = 'tab-1'
|
||||
const PANE_KEY = `${TAB_ID}:leaf-1`
|
||||
|
||||
|
|
@ -192,6 +194,41 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
|||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renames a pending folder workspace title without touching git', async () => {
|
||||
const { deps, onRenamed, setDisplayName } = makeDeps({
|
||||
resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID,
|
||||
getFolderWorkspacePath: () => '/workspace/platform',
|
||||
isPendingFirstAgentMessageRename: () => true,
|
||||
getCurrentDisplayName: () => 'Platform workspace'
|
||||
})
|
||||
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(resolveTextGenerationParamsMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'local',
|
||||
'branchName',
|
||||
null
|
||||
)
|
||||
expect(setDisplayName).toHaveBeenCalledWith(FOLDER_WORKTREE_ID, 'Fix auth')
|
||||
expect(onRenamed).toHaveBeenCalledWith(FOLDER_WORKTREE_ID)
|
||||
})
|
||||
|
||||
it('does not rename folder workspace titles without the pending marker', async () => {
|
||||
const { deps, setDisplayName } = makeDeps({
|
||||
resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID,
|
||||
getFolderWorkspacePath: () => '/workspace/platform',
|
||||
isPendingFirstAgentMessageRename: () => false
|
||||
})
|
||||
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(generateBranchNameMock).not.toHaveBeenCalled()
|
||||
expect(setDisplayName).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores replayed events and non-working states', async () => {
|
||||
const { deps } = makeDeps()
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent({ isReplay: true }), deps)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
// summarize the prompt via the configured agent, and rename.
|
||||
import type { GlobalSettings, Repo } from '../../shared/types'
|
||||
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import { parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import {
|
||||
humanizeBranchSlug,
|
||||
|
|
@ -50,6 +51,10 @@ export type FirstWorkBranchRenameDeps = {
|
|||
getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined
|
||||
/** Current sidebar display name for the worktree, if one is stored. */
|
||||
getCurrentDisplayName: (worktreeId: string) => string | undefined
|
||||
/** Current workspace path for non-git folder workspaces. */
|
||||
getFolderWorkspacePath?: (worktreeId: string) => string | undefined
|
||||
/** True while a workspace title is waiting for the first agent message. */
|
||||
isPendingFirstAgentMessageRename?: (worktreeId: string) => boolean
|
||||
/** True only for Orca-created worktrees whose branch Orca is allowed to rename. */
|
||||
canRenameOrcaCreatedBranch: (worktreeId: string) => boolean
|
||||
/** Persist a new sidebar display name for the worktree. */
|
||||
|
|
@ -164,6 +169,18 @@ async function runAutoRename(
|
|||
return false
|
||||
}
|
||||
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
return runFolderWorkspaceTitleAutoRename(
|
||||
worktreeId,
|
||||
prompt,
|
||||
assistantMessage,
|
||||
deps,
|
||||
stop,
|
||||
retry
|
||||
)
|
||||
}
|
||||
|
||||
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
if (!repo || !parsed) {
|
||||
|
|
@ -276,6 +293,59 @@ async function runAutoRename(
|
|||
return true
|
||||
}
|
||||
|
||||
async function runFolderWorkspaceTitleAutoRename(
|
||||
worktreeId: string,
|
||||
prompt: string,
|
||||
assistantMessage: string | undefined,
|
||||
deps: FirstWorkBranchRenameDeps,
|
||||
stop: (reason: string, clearError?: boolean) => true,
|
||||
retry: (reason: string) => false
|
||||
): Promise<boolean> {
|
||||
if (deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true) {
|
||||
return stop('folder workspace is not pending title rename', true)
|
||||
}
|
||||
const folderPath = deps.getFolderWorkspacePath?.(worktreeId)
|
||||
if (!folderPath) {
|
||||
return stop('folder workspace path unavailable')
|
||||
}
|
||||
|
||||
const settings = deps.getSettings()
|
||||
const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null)
|
||||
if (!resolvedParams.ok) {
|
||||
deps.setRenameError(worktreeId, resolvedParams.error)
|
||||
return stop(`no generation agent: ${resolvedParams.error}`)
|
||||
}
|
||||
const target = await resolveGenerationTarget(
|
||||
folderPath,
|
||||
resolvedParams.params.agentId,
|
||||
null,
|
||||
deps
|
||||
)
|
||||
if (!target) {
|
||||
deps.setRenameError(worktreeId, 'Could not prepare the workspace-name generation environment.')
|
||||
return retry('could not prepare generation environment')
|
||||
}
|
||||
|
||||
const generated = await generateBranchNameFromContext(
|
||||
{ firstPrompt: prompt, assistantMessage },
|
||||
resolvedParams.params,
|
||||
target
|
||||
)
|
||||
if (!generated.success) {
|
||||
if (!generated.canceled) {
|
||||
deps.setRenameError(worktreeId, generated.error)
|
||||
}
|
||||
return retry(`generation failed: ${generated.error}`)
|
||||
}
|
||||
|
||||
const newDisplayName = humanizeBranchSlug(generated.slug)
|
||||
deps.setDisplayName(worktreeId, newDisplayName)
|
||||
deps.setRenameError(worktreeId, null)
|
||||
deps.onRenamed(worktreeId)
|
||||
console.info(`[auto-branch-rename] renamed folder workspace title -> "${newDisplayName}"`)
|
||||
return true
|
||||
}
|
||||
|
||||
async function resolveGenerationTarget(
|
||||
worktreePath: string,
|
||||
agentId: string,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ import { StarNagService } from './star-nag/service'
|
|||
import { agentHookServer } from './agent-hooks/server'
|
||||
import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename'
|
||||
import { getRepoIdFromWorktreeId } from '../shared/worktree-id'
|
||||
import { parseWorkspaceKey } from '../shared/workspace-scope'
|
||||
import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state'
|
||||
import {
|
||||
clearProviderPtyState,
|
||||
|
|
@ -207,7 +208,29 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
|
|||
getSettings: () => currentStore.getSettings(),
|
||||
getRepo: (repoId) => currentStore.getRepo(repoId),
|
||||
getAgentEnvResolvers: () => currentRuntime.getCommitMessageAgentEnvironmentResolvers(),
|
||||
getCurrentDisplayName: (worktreeId) => currentStore.getWorktreeMeta(worktreeId)?.displayName,
|
||||
getCurrentDisplayName: (worktreeId) => {
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
if (scope?.type === 'folder') {
|
||||
return currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.name
|
||||
}
|
||||
return currentStore.getWorktreeMeta(worktreeId)?.displayName
|
||||
},
|
||||
getFolderWorkspacePath: (worktreeId) => {
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
return scope?.type === 'folder'
|
||||
? currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath
|
||||
: undefined
|
||||
},
|
||||
isPendingFirstAgentMessageRename: (worktreeId) => {
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
if (scope?.type === 'folder') {
|
||||
return (
|
||||
currentStore.getFolderWorkspace(scope.folderWorkspaceId)
|
||||
?.pendingFirstAgentMessageRename === true
|
||||
)
|
||||
}
|
||||
return currentStore.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true
|
||||
},
|
||||
canRenameOrcaCreatedBranch: (worktreeId) => {
|
||||
const meta = currentStore.getWorktreeMeta(worktreeId)
|
||||
// Why: a user/imported branch can coincidentally be named after a creature.
|
||||
|
|
@ -215,6 +238,16 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
|
|||
return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true
|
||||
},
|
||||
setDisplayName: (worktreeId, displayName) => {
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
if (scope?.type === 'folder') {
|
||||
currentStore.updateFolderWorkspace(scope.folderWorkspaceId, {
|
||||
name: displayName,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
firstAgentMessageRenameError: null
|
||||
})
|
||||
currentRuntime.notifyFolderWorkspaceChanged()
|
||||
return
|
||||
}
|
||||
currentStore.setWorktreeMeta(worktreeId, {
|
||||
displayName,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
|
|
@ -226,6 +259,20 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
|
|||
setRenameError: (worktreeId, error) => {
|
||||
// Skip the write + renderer push when nothing changes — benign skips
|
||||
// clear the error on every settled worktree, most of which never had one.
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
if (scope?.type === 'folder') {
|
||||
const current = currentStore.getFolderWorkspace(
|
||||
scope.folderWorkspaceId
|
||||
)?.firstAgentMessageRenameError
|
||||
if ((current ?? null) === (error ?? null)) {
|
||||
return
|
||||
}
|
||||
currentStore.updateFolderWorkspace(scope.folderWorkspaceId, {
|
||||
firstAgentMessageRenameError: error
|
||||
})
|
||||
currentRuntime.notifyFolderWorkspaceChanged()
|
||||
return
|
||||
}
|
||||
const current = currentStore.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError
|
||||
if ((current ?? null) === (error ?? null)) {
|
||||
return
|
||||
|
|
@ -236,7 +283,13 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
|
|||
currentRuntime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId))
|
||||
},
|
||||
resolveWorktreeIdForTab: (tabId) => currentStore.getWorktreeIdForTab(tabId),
|
||||
onRenamed: (repoId) => currentRuntime.notifyBranchRenamed(repoId)
|
||||
onRenamed: (repoIdOrWorktreeId) => {
|
||||
if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') {
|
||||
currentRuntime.notifyFolderWorkspaceChanged()
|
||||
return
|
||||
}
|
||||
currentRuntime.notifyBranchRenamed(repoIdOrWorktreeId)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import type { Store } from '../persistence'
|
||||
import type * as RepoWorktrees from '../repo-worktrees'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import type { GitWorktreeInfo, Repo } from '../../shared/types'
|
||||
import type { FolderWorkspace, GitWorktreeInfo, ProjectGroup, Repo } from '../../shared/types'
|
||||
import {
|
||||
invalidateAuthorizedRootsCache,
|
||||
isDescendantOrEqual,
|
||||
|
|
@ -35,9 +35,52 @@ const repo: Repo = {
|
|||
kind: 'git'
|
||||
}
|
||||
|
||||
function makeStore(repos: Repo[] = [repo]): Store {
|
||||
function makeProjectGroup(overrides: Partial<ProjectGroup> = {}): ProjectGroup {
|
||||
return {
|
||||
id: 'group-1',
|
||||
name: 'Workspace',
|
||||
parentPath: '/folders/workspace',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
|
||||
return {
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'Feature',
|
||||
folderPath: '/folders/workspace',
|
||||
comment: '',
|
||||
linkedTask: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeStore(
|
||||
repos: Repo[] = [repo],
|
||||
options: {
|
||||
projectGroups?: ProjectGroup[]
|
||||
folderWorkspaces?: FolderWorkspace[]
|
||||
} = {}
|
||||
): Store {
|
||||
return {
|
||||
getRepos: () => repos,
|
||||
getProjectGroups: () => options.projectGroups ?? [],
|
||||
getFolderWorkspaces: () => options.folderWorkspaces ?? [],
|
||||
getSettings: () => ({})
|
||||
} as unknown as Store
|
||||
}
|
||||
|
|
@ -111,6 +154,113 @@ describe('filesystem-auth path containment', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('authorizes local folder workspace roots outside child repo roots', async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-folder-workspace-'))
|
||||
try {
|
||||
const folderPath = join(tempRoot, 'platform')
|
||||
const repoPath = join(folderPath, 'web')
|
||||
await mkdir(repoPath, { recursive: true })
|
||||
const projectGroup = makeProjectGroup({ parentPath: folderPath })
|
||||
const folderWorkspace = makeFolderWorkspace({ folderPath, projectGroupId: projectGroup.id })
|
||||
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }], {
|
||||
projectGroups: [projectGroup],
|
||||
folderWorkspaces: [folderWorkspace]
|
||||
})
|
||||
|
||||
await expect(resolveAuthorizedPath(folderPath, store)).resolves.toBe(
|
||||
await realpath(folderPath)
|
||||
)
|
||||
await expect(resolveAuthorizedPath(join(folderPath, 'notes.md'), store)).resolves.toBe(
|
||||
join(await realpath(folderPath), 'notes.md')
|
||||
)
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('authorizes local folder-backed project group roots outside child repo roots', async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-project-group-'))
|
||||
try {
|
||||
const folderPath = join(tempRoot, 'platform')
|
||||
const repoPath = join(folderPath, 'web')
|
||||
await mkdir(repoPath, { recursive: true })
|
||||
const projectGroup = makeProjectGroup({ parentPath: folderPath })
|
||||
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }], {
|
||||
projectGroups: [projectGroup]
|
||||
})
|
||||
|
||||
await expect(resolveAuthorizedPath(folderPath, store)).resolves.toBe(
|
||||
await realpath(folderPath)
|
||||
)
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not authorize SSH-only folder workspace roots as local paths', async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-folder-workspace-'))
|
||||
try {
|
||||
const folderPath = join(tempRoot, 'remote-platform')
|
||||
const repoPath = join(folderPath, 'web')
|
||||
await mkdir(repoPath, { recursive: true })
|
||||
const projectGroup = makeProjectGroup({ parentPath: folderPath })
|
||||
const folderWorkspace = makeFolderWorkspace({ folderPath, projectGroupId: projectGroup.id })
|
||||
const store = makeStore(
|
||||
[{ ...repo, id: 'repo-temp', path: repoPath, connectionId: 'ssh-1' }],
|
||||
{
|
||||
projectGroups: [projectGroup],
|
||||
folderWorkspaces: [folderWorkspace]
|
||||
}
|
||||
)
|
||||
|
||||
await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied')
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not authorize repo-less SSH-provenance folder roots as local paths', async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-folder-provenance-'))
|
||||
try {
|
||||
const folderPath = join(tempRoot, 'remote-platform')
|
||||
await mkdir(folderPath, { recursive: true })
|
||||
const projectGroup = makeProjectGroup({ parentPath: folderPath, connectionId: 'ssh-1' })
|
||||
const folderWorkspace = makeFolderWorkspace({
|
||||
folderPath,
|
||||
projectGroupId: projectGroup.id,
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
const store = makeStore([], {
|
||||
projectGroups: [projectGroup],
|
||||
folderWorkspaces: [folderWorkspace]
|
||||
})
|
||||
|
||||
await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied')
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not authorize SSH-only folder-backed project group roots as local paths', async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-project-group-'))
|
||||
try {
|
||||
const folderPath = join(tempRoot, 'remote-platform')
|
||||
const repoPath = join(folderPath, 'web')
|
||||
await mkdir(repoPath, { recursive: true })
|
||||
const projectGroup = makeProjectGroup({ parentPath: folderPath })
|
||||
const store = makeStore(
|
||||
[{ ...repo, id: 'repo-temp', path: repoPath, connectionId: 'ssh-1' }],
|
||||
{
|
||||
projectGroups: [projectGroup]
|
||||
}
|
||||
)
|
||||
|
||||
await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied')
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rejects missing descendants under a symlinked ancestor outside the repo',
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import { realpath } from 'fs/promises'
|
|||
import type { Store } from '../persistence'
|
||||
import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees'
|
||||
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import { getProjectGroupSubtreeIds } from '../../shared/project-groups'
|
||||
import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types'
|
||||
|
||||
export const PATH_ACCESS_DENIED_MESSAGE =
|
||||
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
|
||||
|
|
@ -17,6 +20,8 @@ const registeredWorktreeRootRepoIds = new Set<string>()
|
|||
let registeredWorktreeRootsDirty = true
|
||||
let registeredWorktreeRootsRefresh: Promise<void> | null = null
|
||||
const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8
|
||||
type FolderScopeStore = Pick<Store, 'getRepos'> &
|
||||
Partial<Pick<Store, 'getProjectGroups' | 'getFolderWorkspaces'>>
|
||||
|
||||
export function authorizeExternalPath(targetPath: string): void {
|
||||
const resolvedTarget = resolve(targetPath)
|
||||
|
|
@ -43,6 +48,75 @@ function getLocalRepos(store: Store) {
|
|||
return store.getRepos().filter((repo) => !repo.connectionId)
|
||||
}
|
||||
|
||||
function getFolderScopeCandidateRepos(
|
||||
folderPath: string,
|
||||
projectGroupId: string,
|
||||
projectGroups: readonly ProjectGroup[],
|
||||
repos: readonly Repo[]
|
||||
): Repo[] {
|
||||
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId)
|
||||
return repos.filter(
|
||||
(repo) =>
|
||||
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
|
||||
isPathInsideOrEqual(folderPath, repo.path)
|
||||
)
|
||||
}
|
||||
|
||||
function isRemoteOnlyFolderScope(
|
||||
folderPath: string,
|
||||
projectGroupId: string,
|
||||
connectionId: string | null | undefined,
|
||||
projectGroups: readonly ProjectGroup[],
|
||||
repos: readonly Repo[]
|
||||
): boolean {
|
||||
if (connectionId) {
|
||||
return true
|
||||
}
|
||||
const candidates = getFolderScopeCandidateRepos(folderPath, projectGroupId, projectGroups, repos)
|
||||
return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId))
|
||||
}
|
||||
|
||||
function getFolderWorkspaceConnectionId(
|
||||
workspace: FolderWorkspace,
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
): string | null {
|
||||
return (
|
||||
workspace.connectionId ??
|
||||
projectGroups.find((group) => group.id === workspace.projectGroupId)?.connectionId ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function getLocalFolderScopeRoots(store: Store): string[] {
|
||||
const scopeStore = store as FolderScopeStore
|
||||
const repos = scopeStore.getRepos()
|
||||
// Why: many filesystem tests use narrow Store doubles; folder scopes are additive.
|
||||
const projectGroups = scopeStore.getProjectGroups?.() ?? []
|
||||
const roots: string[] = []
|
||||
for (const group of projectGroups) {
|
||||
if (
|
||||
group.parentPath &&
|
||||
!isRemoteOnlyFolderScope(group.parentPath, group.id, group.connectionId, projectGroups, repos)
|
||||
) {
|
||||
roots.push(resolve(group.parentPath))
|
||||
}
|
||||
}
|
||||
for (const workspace of scopeStore.getFolderWorkspaces?.() ?? []) {
|
||||
if (
|
||||
!isRemoteOnlyFolderScope(
|
||||
workspace.folderPath,
|
||||
workspace.projectGroupId,
|
||||
getFolderWorkspaceConnectionId(workspace, projectGroups),
|
||||
projectGroups,
|
||||
repos
|
||||
)
|
||||
) {
|
||||
roots.push(resolve(workspace.folderPath))
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether resolvedTarget is equal to or a descendant of resolvedBase.
|
||||
* Uses relative() so it works with both `/` (Unix) and `\` (Windows) separators.
|
||||
|
|
@ -63,7 +137,10 @@ export function isDescendantOrEqual(resolvedTarget: string, resolvedBase: string
|
|||
export function getAllowedRoots(store: Store): string[] {
|
||||
const localRepos = getLocalRepos(store)
|
||||
const settings = store.getSettings()
|
||||
const roots = localRepos.map((repo) => resolve(repo.path))
|
||||
const roots = [
|
||||
...localRepos.map((repo) => resolve(repo.path)),
|
||||
...getLocalFolderScopeRoots(store)
|
||||
]
|
||||
if (settings.workspaceDir) {
|
||||
if (localRepos.length === 0) {
|
||||
roots.push(resolve(settings.workspaceDir))
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
|
|||
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
|
||||
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
|
||||
import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy'
|
||||
import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import {
|
||||
assertFolderWorkspacePathUsable,
|
||||
getFolderWorkspacePathStatus
|
||||
} from '../project-groups/folder-workspace-path-status'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
|
||||
// ─── Provider Registry ──────────────────────────────────────────────
|
||||
// Routes PTY operations by connectionId. null = local provider.
|
||||
|
|
@ -1569,6 +1575,21 @@ export function registerPtyHandlers(
|
|||
mainWindow.webContents.on('did-finish-load', didFinishLoadHandler)
|
||||
}
|
||||
|
||||
const assertFolderWorkspacePtyPathUsable = async (
|
||||
worktreeId: string | undefined
|
||||
): Promise<void> => {
|
||||
const workspaceScope = typeof worktreeId === 'string' ? parseWorkspaceKey(worktreeId) : null
|
||||
if (!store || workspaceScope?.type !== 'folder') {
|
||||
return
|
||||
}
|
||||
const status = await getFolderWorkspacePathStatus(
|
||||
store,
|
||||
{ scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId },
|
||||
{ getSshFilesystemProvider }
|
||||
)
|
||||
assertFolderWorkspacePathUsable(status)
|
||||
}
|
||||
|
||||
// Why: the runtime controller must route through getProviderForPty() so that
|
||||
// CLI commands (terminal.send, terminal.stop) work for both local and remote PTYs.
|
||||
// Hardcoding localProvider.getPtyProcess() would silently fail for remote PTYs.
|
||||
|
|
@ -1578,6 +1599,7 @@ export function registerPtyHandlers(
|
|||
if (startupPromise) {
|
||||
await startupPromise
|
||||
}
|
||||
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
|
||||
const provider = getProvider(args.connectionId)
|
||||
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
|
||||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
|
|
@ -2030,6 +2052,7 @@ export function registerPtyHandlers(
|
|||
if (startupPromise) {
|
||||
await startupPromise
|
||||
}
|
||||
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
|
||||
const provider = getProvider(args.connectionId)
|
||||
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
|
||||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
|
|
|
|||
|
|
@ -11,16 +11,19 @@ import type {
|
|||
BaseRefSearchResult,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
FolderWorkspace,
|
||||
ProjectGroupImportResult,
|
||||
NestedRepoScanResult,
|
||||
BaseRefDefaultResult,
|
||||
SparsePreset
|
||||
} from '../../shared/types'
|
||||
import type { FolderWorkspacePathStatusRequest } from '../../shared/folder-workspace-path-status'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
|
||||
import { normalizeRepoBadgeColor } from '../../shared/repo-badge-color'
|
||||
import { sanitizeRepoIcon } from '../../shared/repo-icon'
|
||||
import { normalizeRepoSourceControlAiOverrides } from '../../shared/source-control-ai'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
|
||||
import type { ChildProcess } from 'child_process'
|
||||
import { access, mkdir, readdir, rm } from 'fs/promises'
|
||||
|
|
@ -62,6 +65,11 @@ import { track } from '../telemetry/client'
|
|||
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
||||
import type { RepoMethod } from '../../shared/telemetry-events'
|
||||
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
||||
import {
|
||||
assertFolderWorkspacePathUsable,
|
||||
getFolderWorkspacePathStatus,
|
||||
getFolderWorkspacePathStatusForPath
|
||||
} from '../project-groups/folder-workspace-path-status'
|
||||
|
||||
// Why: `method` answers "which entry point did the user take?", not "what did
|
||||
// they add?" — so the IPC the renderer invoked IS the method. We never send
|
||||
|
|
@ -136,6 +144,7 @@ const GIT_AVAILABILITY_TIMEOUT_MS = 1500
|
|||
const ProjectGroupCreateArgs = z.object({
|
||||
name: z.string().min(1),
|
||||
parentPath: z.string().nullable().optional(),
|
||||
connectionId: z.string().nullable().optional(),
|
||||
parentGroupId: z.string().nullable().optional(),
|
||||
createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional()
|
||||
})
|
||||
|
|
@ -160,6 +169,64 @@ const ProjectGroupMoveProjectArgs = z.object({
|
|||
order: z.number().finite().optional()
|
||||
})
|
||||
|
||||
const FolderWorkspaceLinkedTaskArgs = z
|
||||
.object({
|
||||
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
|
||||
type: z.enum(['issue', 'pr', 'mr']),
|
||||
number: z.number().finite(),
|
||||
title: z.string().min(1),
|
||||
url: z.string().min(1),
|
||||
linearIdentifier: z.string().min(1).optional(),
|
||||
jiraIdentifier: z.string().min(1).optional(),
|
||||
repoId: z.string().min(1).optional()
|
||||
})
|
||||
.nullable()
|
||||
|
||||
const FolderWorkspaceCreateArgs = z.object({
|
||||
projectGroupId: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
folderPath: z.string().nullable().optional(),
|
||||
connectionId: z.string().nullable().optional(),
|
||||
linkedTask: FolderWorkspaceLinkedTaskArgs.optional(),
|
||||
createdWithAgent: z.string().refine(isTuiAgent).optional(),
|
||||
pendingFirstAgentMessageRename: z.boolean().optional()
|
||||
})
|
||||
|
||||
const FolderWorkspaceUpdateArgs = z.object({
|
||||
folderWorkspaceId: z.string().min(1),
|
||||
updates: z.object({
|
||||
name: z.string().optional(),
|
||||
folderPath: z.string().optional(),
|
||||
linkedTask: FolderWorkspaceLinkedTaskArgs.optional(),
|
||||
comment: z.string().optional(),
|
||||
isArchived: z.boolean().optional(),
|
||||
isUnread: z.boolean().optional(),
|
||||
isPinned: z.boolean().optional(),
|
||||
sortOrder: z.number().finite().optional(),
|
||||
manualOrder: z.number().finite().optional(),
|
||||
workspaceStatus: z.string().optional(),
|
||||
createdWithAgent: z.string().refine(isTuiAgent).optional(),
|
||||
pendingFirstAgentMessageRename: z.boolean().optional(),
|
||||
firstAgentMessageRenameError: z.string().nullable().optional(),
|
||||
lastActivityAt: z.number().finite().optional()
|
||||
})
|
||||
})
|
||||
|
||||
const FolderWorkspaceSelectorArgs = z.object({
|
||||
folderWorkspaceId: z.string().min(1)
|
||||
})
|
||||
|
||||
const FolderWorkspacePathStatusArgs = z.discriminatedUnion('scope', [
|
||||
z.object({
|
||||
scope: z.literal('folder-workspace'),
|
||||
folderWorkspaceId: z.string().min(1)
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal('project-group'),
|
||||
projectGroupId: z.string().min(1)
|
||||
})
|
||||
])
|
||||
|
||||
const ProjectGroupScanNestedArgs = z.object({
|
||||
path: z.string().min(1),
|
||||
connectionId: z.string().min(1).optional(),
|
||||
|
|
@ -470,6 +537,11 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
ipcMain.removeHandler('projectGroups:scanNested')
|
||||
ipcMain.removeHandler('projectGroups:cancelNestedScan')
|
||||
ipcMain.removeHandler('projectGroups:importNested')
|
||||
ipcMain.removeHandler('folderWorkspaces:list')
|
||||
ipcMain.removeHandler('folderWorkspaces:create')
|
||||
ipcMain.removeHandler('folderWorkspaces:update')
|
||||
ipcMain.removeHandler('folderWorkspaces:delete')
|
||||
ipcMain.removeHandler('folderWorkspaces:getPathStatus')
|
||||
ipcMain.removeHandler('repos:pickFolder')
|
||||
ipcMain.removeHandler('repos:pickDirectory')
|
||||
ipcMain.removeHandler('repos:clone')
|
||||
|
|
@ -495,6 +567,104 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
|
||||
ipcMain.handle('projectGroups:list', () => store.getProjectGroups())
|
||||
|
||||
ipcMain.handle('folderWorkspaces:list', (): FolderWorkspace[] => store.getFolderWorkspaces())
|
||||
|
||||
ipcMain.handle('folderWorkspaces:getPathStatus', async (_event, rawArgs: unknown) => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
FolderWorkspacePathStatusArgs,
|
||||
rawArgs,
|
||||
'invalid_folder_workspace_path_status_args'
|
||||
) as FolderWorkspacePathStatusRequest
|
||||
return getFolderWorkspacePathStatus(store, args, { getSshFilesystemProvider })
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'folderWorkspaces:create',
|
||||
async (_event, rawArgs: unknown): Promise<FolderWorkspace> => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
FolderWorkspaceCreateArgs,
|
||||
rawArgs,
|
||||
'invalid_folder_workspace_create_args'
|
||||
)
|
||||
const projectGroups = store.getProjectGroups()
|
||||
const group = projectGroups.find((entry) => entry.id === args.projectGroupId)
|
||||
const folderPath =
|
||||
typeof args.folderPath === 'string' && args.folderPath.trim().length > 0
|
||||
? args.folderPath
|
||||
: group?.parentPath
|
||||
if (!group || !folderPath) {
|
||||
throw new Error('folder_workspace_project_group_not_found')
|
||||
}
|
||||
const status = await getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath,
|
||||
projectGroupId: group.id,
|
||||
connectionId: args.connectionId ?? group.connectionId ?? null,
|
||||
projectGroups,
|
||||
repos: store.getRepos()
|
||||
},
|
||||
{ getSshFilesystemProvider }
|
||||
)
|
||||
assertFolderWorkspacePathUsable(status)
|
||||
const workspace = store.createFolderWorkspace(args)
|
||||
notifyReposChanged(mainWindow)
|
||||
return workspace
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'folderWorkspaces:update',
|
||||
async (_event, rawArgs: unknown): Promise<FolderWorkspace | null> => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
FolderWorkspaceUpdateArgs,
|
||||
rawArgs,
|
||||
'invalid_folder_workspace_update_args'
|
||||
)
|
||||
if (
|
||||
typeof args.updates.folderPath === 'string' &&
|
||||
args.updates.folderPath.trim().length > 0
|
||||
) {
|
||||
const workspace = store.getFolderWorkspace(args.folderWorkspaceId)
|
||||
if (!workspace) {
|
||||
return null
|
||||
}
|
||||
const projectGroups = store.getProjectGroups()
|
||||
const status = await getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath: args.updates.folderPath,
|
||||
projectGroupId: workspace.projectGroupId,
|
||||
connectionId:
|
||||
workspace.connectionId ??
|
||||
projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ??
|
||||
null,
|
||||
projectGroups,
|
||||
repos: store.getRepos()
|
||||
},
|
||||
{ getSshFilesystemProvider }
|
||||
)
|
||||
assertFolderWorkspacePathUsable(status)
|
||||
}
|
||||
const updated = store.updateFolderWorkspace(args.folderWorkspaceId, args.updates)
|
||||
if (updated) {
|
||||
notifyReposChanged(mainWindow)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('folderWorkspaces:delete', (_event, rawArgs: unknown): boolean => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
FolderWorkspaceSelectorArgs,
|
||||
rawArgs,
|
||||
'invalid_folder_workspace_delete_args'
|
||||
)
|
||||
const deleted = store.removeFolderWorkspace(args.folderWorkspaceId)
|
||||
if (deleted) {
|
||||
notifyReposChanged(mainWindow)
|
||||
}
|
||||
return deleted
|
||||
})
|
||||
|
||||
ipcMain.handle('projectGroups:create', (_event, rawArgs: unknown): ProjectGroup => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
ProjectGroupCreateArgs,
|
||||
|
|
@ -504,6 +674,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
const group = store.createProjectGroup({
|
||||
name: args.name,
|
||||
parentPath: args.parentPath ?? null,
|
||||
connectionId: args.connectionId ?? null,
|
||||
parentGroupId: args.parentGroupId ?? null,
|
||||
createdFrom: args.createdFrom ?? 'manual'
|
||||
})
|
||||
|
|
@ -598,6 +769,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
parentPath: scan.selectedPath,
|
||||
groupName: args.groupName ?? '',
|
||||
mode: args.mode,
|
||||
connectionId: args.connectionId ?? null,
|
||||
repoPaths: selection.selectedPaths,
|
||||
createGroup: (input) => store.createProjectGroup(input)
|
||||
})
|
||||
const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
ONBOARDING_FINAL_STEP,
|
||||
ONBOARDING_FLOW_VERSION
|
||||
} from '../shared/constants'
|
||||
import { folderWorkspaceKey } from '../shared/workspace-scope'
|
||||
import { SshConnectionStore } from './ssh/ssh-connection-store'
|
||||
|
||||
// Shared mutable state so the electron mock can reference a per-test directory
|
||||
|
|
@ -2144,6 +2145,57 @@ describe('Store', () => {
|
|||
expect(store.getRepo('sibling')?.projectGroupId).toBe(sibling.id)
|
||||
})
|
||||
|
||||
it('adapts flat folder-scan groups into sparse nested folder scopes on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [
|
||||
makeRepo({ id: 'api', path: '/workspace/platform/api', projectGroupId: 'root' }),
|
||||
makeRepo({ id: 'web', path: '/workspace/platform/web', projectGroupId: 'root' }),
|
||||
makeRepo({
|
||||
id: 'repo1',
|
||||
path: '/workspace/platform/packages/shared/repo1',
|
||||
projectGroupId: 'root'
|
||||
}),
|
||||
makeRepo({
|
||||
id: 'repo2',
|
||||
path: '/workspace/platform/packages/shared/repo2',
|
||||
projectGroupId: 'root'
|
||||
})
|
||||
],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'root',
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
const groups = store.getProjectGroups()
|
||||
const shared = groups.find((group) => group.name === 'packages/shared')
|
||||
|
||||
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
|
||||
['Platform', null, '/workspace/platform'],
|
||||
['packages/shared', 'root', '/workspace/platform/packages/shared']
|
||||
])
|
||||
expect(store.getRepo('api')?.projectGroupId).toBe('root')
|
||||
expect(store.getRepo('web')?.projectGroupId).toBe('root')
|
||||
expect(store.getRepo('repo1')?.projectGroupId).toBe(shared?.id)
|
||||
expect(store.getRepo('repo2')?.projectGroupId).toBe(shared?.id)
|
||||
})
|
||||
|
||||
it('creates a project group when persisted group history is very large', async () => {
|
||||
const projectGroups: ProjectGroup[] = Array.from({ length: 130_000 }, (_, index) => ({
|
||||
id: `group-${index}`,
|
||||
|
|
@ -2525,6 +2577,319 @@ describe('Store', () => {
|
|||
expect(updated.comment).toBe('updated')
|
||||
})
|
||||
|
||||
it('creates and updates folder workspaces from folder-backed project groups', async () => {
|
||||
const store = await createStore()
|
||||
const group = store.createProjectGroup({
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
const linkedTask = {
|
||||
provider: 'linear' as const,
|
||||
type: 'issue' as const,
|
||||
number: 0,
|
||||
title: 'Refund fix',
|
||||
url: 'https://linear.app/acme/issue/ENG-123',
|
||||
linearIdentifier: 'ENG-123'
|
||||
}
|
||||
|
||||
const workspace = store.createFolderWorkspace({
|
||||
projectGroupId: group.id,
|
||||
name: 'Refund fix',
|
||||
linkedTask
|
||||
})
|
||||
const updated = store.updateFolderWorkspace(workspace.id, {
|
||||
comment: 'Coordinate api and web',
|
||||
isPinned: true,
|
||||
lastActivityAt: 123
|
||||
})
|
||||
|
||||
expect(workspace.folderPath).toBe('/workspace/platform')
|
||||
expect(updated).toMatchObject({
|
||||
id: workspace.id,
|
||||
projectGroupId: group.id,
|
||||
name: 'Refund fix',
|
||||
folderPath: '/workspace/platform',
|
||||
linkedTask,
|
||||
comment: 'Coordinate api and web',
|
||||
isPinned: true,
|
||||
lastActivityAt: 123
|
||||
})
|
||||
expect(store.getFolderWorkspaces()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects folder workspace creation for non-folder-backed project groups', async () => {
|
||||
const store = await createStore()
|
||||
const group = store.createProjectGroup({ name: 'Manual', createdFrom: 'manual' })
|
||||
|
||||
expect(() => store.createFolderWorkspace({ projectGroupId: group.id })).toThrow(
|
||||
'Folder-backed project group not found.'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes persisted folder workspaces and drops orphaned records', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'root',
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'fw-1',
|
||||
projectGroupId: 'root',
|
||||
name: ' ',
|
||||
folderPath: '',
|
||||
comment: 42,
|
||||
isArchived: true,
|
||||
isUnread: true,
|
||||
isPinned: false,
|
||||
sortOrder: 10,
|
||||
lastActivityAt: 5,
|
||||
createdAt: 2,
|
||||
updatedAt: 3
|
||||
},
|
||||
{
|
||||
id: 'orphan',
|
||||
projectGroupId: 'missing',
|
||||
name: 'Orphan',
|
||||
folderPath: '/missing'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getFolderWorkspaces()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'fw-1',
|
||||
projectGroupId: 'root',
|
||||
name: 'Untitled workspace',
|
||||
folderPath: '/workspace/platform',
|
||||
comment: '',
|
||||
isArchived: true,
|
||||
isUnread: true
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('backfills folder-scope SSH provenance from unambiguous child repos on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [
|
||||
makeRepo({
|
||||
id: 'api',
|
||||
path: '/workspace/platform/api',
|
||||
projectGroupId: 'root',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'root',
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'fw-1',
|
||||
projectGroupId: 'root',
|
||||
name: 'Refund fix',
|
||||
folderPath: '/workspace/platform',
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getProjectGroups()[0]).toMatchObject({ id: 'root', connectionId: 'ssh-1' })
|
||||
expect(store.getFolderWorkspaces()[0]).toMatchObject({ id: 'fw-1', connectionId: 'ssh-1' })
|
||||
})
|
||||
|
||||
it('backfills folder-scope SSH provenance from grouped repos despite unrelated same-path SSH repos', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [
|
||||
makeRepo({
|
||||
id: 'api-ssh-1',
|
||||
path: '/workspace/platform/api',
|
||||
projectGroupId: 'root',
|
||||
connectionId: 'ssh-1'
|
||||
}),
|
||||
makeRepo({
|
||||
id: 'api-ssh-2',
|
||||
path: '/workspace/platform/api',
|
||||
projectGroupId: 'other-root',
|
||||
connectionId: 'ssh-2'
|
||||
})
|
||||
],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'root',
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'other-root',
|
||||
name: 'Platform other',
|
||||
parentPath: '/workspace/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 1,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'fw-1',
|
||||
projectGroupId: 'root',
|
||||
name: 'Refund fix',
|
||||
folderPath: '/workspace/platform',
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getProjectGroups().find((group) => group.id === 'root')).toMatchObject({
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
expect(store.getFolderWorkspaces()[0]).toMatchObject({ id: 'fw-1', connectionId: 'ssh-1' })
|
||||
})
|
||||
|
||||
it('removes folder workspace metadata and its scoped session state only', async () => {
|
||||
const store = await createStore()
|
||||
const group = store.createProjectGroup({
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
store.addRepo(
|
||||
makeRepo({ id: 'api', path: '/workspace/platform/api', projectGroupId: group.id })
|
||||
)
|
||||
const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Refund fix' })
|
||||
const key = folderWorkspaceKey(workspace.id)
|
||||
const tab = makeTerminalTab({ id: 'folder-tab', worktreeId: key })
|
||||
store.setWorkspaceSession({
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeWorkspaceKey: key,
|
||||
activeWorktreeId: key,
|
||||
activeTabId: tab.id,
|
||||
tabsByWorktree: { [key]: [tab], 'repo::/wt': [makeTerminalTab({ id: 'repo-tab' })] },
|
||||
terminalLayoutsByTabId: {
|
||||
[tab.id]: { root: null, activeLeafId: null, expandedLeafId: null },
|
||||
'repo-tab': { root: null, activeLeafId: null, expandedLeafId: null }
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
[key]: [
|
||||
{
|
||||
id: 'browser-workspace',
|
||||
worktreeId: key,
|
||||
url: 'about:blank',
|
||||
title: 'Blank',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'browser-workspace': [
|
||||
{
|
||||
id: 'page-1',
|
||||
workspaceId: 'browser-workspace',
|
||||
worktreeId: key,
|
||||
url: 'about:blank',
|
||||
title: 'Blank',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
activeTabIdByWorktree: { [key]: tab.id },
|
||||
lastVisitedAtByWorktreeId: { [key]: 10 }
|
||||
})
|
||||
|
||||
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
|
||||
|
||||
const session = store.getWorkspaceSession()
|
||||
expect(store.getFolderWorkspaces()).toEqual([])
|
||||
expect(store.getProjectGroups()).toHaveLength(1)
|
||||
expect(store.getRepo('api')?.projectGroupId).toBe(group.id)
|
||||
expect(session.activeWorkspaceKey).toBeNull()
|
||||
expect(session.activeWorktreeId).toBeNull()
|
||||
expect(session.activeTabId).toBeNull()
|
||||
expect(session.tabsByWorktree[key]).toBeUndefined()
|
||||
expect(session.tabsByWorktree['repo::/wt']).toHaveLength(1)
|
||||
expect(session.terminalLayoutsByTabId['folder-tab']).toBeUndefined()
|
||||
expect(session.terminalLayoutsByTabId['repo-tab']).toBeDefined()
|
||||
expect(session.browserPagesByWorkspace?.['browser-workspace']).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── 9. Settings: get/update ────────────────────────────────────────
|
||||
|
||||
it('updateSettings merges partial updates', async () => {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import type {
|
|||
PersistedState,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
FolderWorkspace,
|
||||
SparsePreset,
|
||||
WorktreeMeta,
|
||||
WorktreeLineage,
|
||||
|
|
@ -85,7 +86,10 @@ import { agentHookServer } from './agent-hooks/server'
|
|||
import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers'
|
||||
import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history'
|
||||
import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../shared/worktree-id'
|
||||
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../shared/cross-platform-path'
|
||||
import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands'
|
||||
import { normalizeTaskProviderSettings } from '../shared/task-providers'
|
||||
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-rename-branch-from-work-settings'
|
||||
|
|
@ -121,6 +125,7 @@ import {
|
|||
normalizeProjectGroupName,
|
||||
normalizeProjectGroups
|
||||
} from '../shared/project-groups'
|
||||
import { createNestedProjectGroupResolver } from './project-groups/nested-repo-import'
|
||||
import {
|
||||
mergeLegacyCommitMessageAiIntoSourceControlAi,
|
||||
normalizeRepoSourceControlAiOverrides,
|
||||
|
|
@ -139,6 +144,11 @@ import {
|
|||
import { normalizeTerminalCursorStyleDefault } from '../shared/terminal-cursor-style-settings'
|
||||
import { normalizeUiLanguage } from '../shared/ui-language'
|
||||
import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom'
|
||||
import {
|
||||
normalizeFolderWorkspaceName,
|
||||
normalizeFolderWorkspaces
|
||||
} from '../shared/folder-workspaces'
|
||||
import { folderWorkspaceKey } from '../shared/workspace-scope'
|
||||
import {
|
||||
collectTerminalScrollbackSnapshotRefs,
|
||||
deleteTerminalScrollbackSnapshotSync,
|
||||
|
|
@ -1642,6 +1652,167 @@ function cloneWorkspaceSessionState(session: WorkspaceSessionState): WorkspaceSe
|
|||
return structuredClone(session)
|
||||
}
|
||||
|
||||
function removeWorkspaceSessionOwner(
|
||||
session: WorkspaceSessionState | undefined,
|
||||
ownerKey: string
|
||||
): WorkspaceSessionState | undefined {
|
||||
if (!session) {
|
||||
return session
|
||||
}
|
||||
const next = cloneWorkspaceSessionState(session)
|
||||
const removedTerminalTabs = next.tabsByWorktree?.[ownerKey] ?? []
|
||||
if (next.tabsByWorktree) {
|
||||
delete next.tabsByWorktree[ownerKey]
|
||||
}
|
||||
for (const tab of removedTerminalTabs) {
|
||||
delete next.terminalLayoutsByTabId[tab.id]
|
||||
if (next.activeTabId === tab.id) {
|
||||
next.activeTabId = null
|
||||
}
|
||||
}
|
||||
|
||||
if (next.openFilesByWorktree) {
|
||||
delete next.openFilesByWorktree[ownerKey]
|
||||
}
|
||||
if (next.activeFileIdByWorktree) {
|
||||
delete next.activeFileIdByWorktree[ownerKey]
|
||||
}
|
||||
const browserWorkspaces = next.browserTabsByWorktree?.[ownerKey] ?? []
|
||||
if (next.browserTabsByWorktree) {
|
||||
delete next.browserTabsByWorktree[ownerKey]
|
||||
}
|
||||
if (next.browserPagesByWorkspace) {
|
||||
for (const workspace of browserWorkspaces) {
|
||||
delete next.browserPagesByWorkspace[workspace.id]
|
||||
}
|
||||
}
|
||||
if (next.activeBrowserTabIdByWorktree) {
|
||||
delete next.activeBrowserTabIdByWorktree[ownerKey]
|
||||
}
|
||||
if (next.activeTabTypeByWorktree) {
|
||||
delete next.activeTabTypeByWorktree[ownerKey]
|
||||
}
|
||||
if (next.activeTabIdByWorktree) {
|
||||
delete next.activeTabIdByWorktree[ownerKey]
|
||||
}
|
||||
if (next.unifiedTabs) {
|
||||
delete next.unifiedTabs[ownerKey]
|
||||
}
|
||||
if (next.tabGroups) {
|
||||
delete next.tabGroups[ownerKey]
|
||||
}
|
||||
if (next.tabGroupLayouts) {
|
||||
delete next.tabGroupLayouts[ownerKey]
|
||||
}
|
||||
if (next.activeGroupIdByWorktree) {
|
||||
delete next.activeGroupIdByWorktree[ownerKey]
|
||||
}
|
||||
if (next.lastVisitedAtByWorktreeId) {
|
||||
delete next.lastVisitedAtByWorktreeId[ownerKey]
|
||||
}
|
||||
if (next.defaultTerminalTabsAppliedByWorktreeId) {
|
||||
delete next.defaultTerminalTabsAppliedByWorktreeId[ownerKey]
|
||||
}
|
||||
if (next.sleepingAgentSessionsByPaneKey) {
|
||||
for (const [paneKey, record] of Object.entries(next.sleepingAgentSessionsByPaneKey)) {
|
||||
if (record.worktreeId === ownerKey) {
|
||||
delete next.sleepingAgentSessionsByPaneKey[paneKey]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (next.activeWorkspaceKey === ownerKey) {
|
||||
next.activeWorkspaceKey = null
|
||||
}
|
||||
if (next.activeWorktreeId === ownerKey) {
|
||||
next.activeWorktreeId = null
|
||||
}
|
||||
next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter(
|
||||
(worktreeId) => worktreeId !== ownerKey
|
||||
)
|
||||
return next
|
||||
}
|
||||
|
||||
function inferFolderScopeConnectionIdForMigration(args: {
|
||||
folderPath: string
|
||||
projectGroupId: string
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
repos: readonly Repo[]
|
||||
}): string | null {
|
||||
const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
|
||||
const groupRepos = args.repos.filter(
|
||||
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
|
||||
)
|
||||
const candidateRepos =
|
||||
groupRepos.length > 0
|
||||
? groupRepos
|
||||
: args.repos.filter((repo) => isPathInsideOrEqual(args.folderPath, repo.path))
|
||||
if (candidateRepos.length === 0) {
|
||||
return null
|
||||
}
|
||||
let hasLocalRepo = false
|
||||
const connectionIds = new Set<string>()
|
||||
for (const repo of candidateRepos) {
|
||||
if (repo.connectionId) {
|
||||
connectionIds.add(repo.connectionId)
|
||||
} else {
|
||||
hasLocalRepo = true
|
||||
}
|
||||
}
|
||||
if (hasLocalRepo || connectionIds.size !== 1) {
|
||||
return null
|
||||
}
|
||||
return [...connectionIds][0]
|
||||
}
|
||||
|
||||
function backfillFolderScopeConnectionIds(state: PersistedState): {
|
||||
state: PersistedState
|
||||
changed: boolean
|
||||
} {
|
||||
const groups = state.projectGroups ?? []
|
||||
const repos = state.repos ?? []
|
||||
let changed = false
|
||||
const projectGroups = groups.map((group) => {
|
||||
if (group.connectionId || !group.parentPath) {
|
||||
return group
|
||||
}
|
||||
const connectionId = inferFolderScopeConnectionIdForMigration({
|
||||
folderPath: group.parentPath,
|
||||
projectGroupId: group.id,
|
||||
projectGroups: groups,
|
||||
repos
|
||||
})
|
||||
if (!connectionId) {
|
||||
return group
|
||||
}
|
||||
changed = true
|
||||
return { ...group, connectionId }
|
||||
})
|
||||
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
|
||||
const folderWorkspaces = (state.folderWorkspaces ?? []).map((workspace) => {
|
||||
if (workspace.connectionId) {
|
||||
return workspace
|
||||
}
|
||||
const groupConnectionId = groupsById.get(workspace.projectGroupId)?.connectionId ?? null
|
||||
const connectionId =
|
||||
groupConnectionId ??
|
||||
inferFolderScopeConnectionIdForMigration({
|
||||
folderPath: workspace.folderPath,
|
||||
projectGroupId: workspace.projectGroupId,
|
||||
projectGroups,
|
||||
repos
|
||||
})
|
||||
if (!connectionId) {
|
||||
return workspace
|
||||
}
|
||||
changed = true
|
||||
return { ...workspace, connectionId }
|
||||
})
|
||||
return {
|
||||
changed,
|
||||
state: changed ? { ...state, projectGroups, folderWorkspaces } : state
|
||||
}
|
||||
}
|
||||
|
||||
function deleteRemovedTerminalScrollbackSnapshots(
|
||||
prior: WorkspaceSessionState | undefined,
|
||||
next: WorkspaceSessionState
|
||||
|
|
@ -1676,6 +1847,7 @@ export class Store {
|
|||
const loaded = this.load()
|
||||
const normalized = normalizePersistedPaneIdentityState(loaded)
|
||||
this.state = normalized.state
|
||||
const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups()
|
||||
for (const entry of normalized.migrationUnsupportedEntries) {
|
||||
setMigrationUnsupportedPty(entry)
|
||||
}
|
||||
|
|
@ -1696,7 +1868,7 @@ export class Store {
|
|||
this.state.legacyPaneKeyAliasEntries = entries
|
||||
this.scheduleSave()
|
||||
})
|
||||
if (normalized.changed || this.loadNeedsSave) {
|
||||
if (normalized.changed || this.loadNeedsSave || adaptedProjectGroups) {
|
||||
// Why: upgraded sessions may contain legacy pane:1 leaves. Rewrite them at
|
||||
// the main persistence boundary so older renderer writes cannot revive them.
|
||||
// Other one-shot load migrations also set loadNeedsSave to persist their
|
||||
|
|
@ -1705,6 +1877,86 @@ export class Store {
|
|||
}
|
||||
}
|
||||
|
||||
private adaptFlatFolderScanProjectGroups(): boolean {
|
||||
// Why: older folder imports persisted a real parent path but kept all repos
|
||||
// flat. Upgrade that shape into v1 sparse folder scopes on load.
|
||||
const groups = this.state.projectGroups ?? []
|
||||
const repos = this.state.repos
|
||||
if (groups.length === 0 || repos.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let changed = false
|
||||
let maxOrder = -1
|
||||
for (const group of groups) {
|
||||
maxOrder = Math.max(maxOrder, group.tabOrder)
|
||||
}
|
||||
|
||||
const childGroupIds = new Set(
|
||||
groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : []))
|
||||
)
|
||||
const initialGroupCount = groups.length
|
||||
for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) {
|
||||
const rootGroup = groups[groupIndex]
|
||||
if (!rootGroup) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
rootGroup.createdFrom !== 'folder-scan' ||
|
||||
!rootGroup.parentPath ||
|
||||
rootGroup.parentGroupId ||
|
||||
childGroupIds.has(rootGroup.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const rootPath = rootGroup.parentPath
|
||||
const repoCandidates = repos.filter(
|
||||
(repo) =>
|
||||
!isFolderRepo(repo) &&
|
||||
repo.projectGroupId === rootGroup.id &&
|
||||
isPathInsideOrEqual(rootPath, repo.path)
|
||||
)
|
||||
if (repoCandidates.length < 2) {
|
||||
continue
|
||||
}
|
||||
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: rootPath,
|
||||
groupName: rootGroup.name,
|
||||
mode: 'group',
|
||||
repoPaths: repoCandidates.map((repo) => repo.path),
|
||||
createGroup: (input) => {
|
||||
if (!input.parentGroupId) {
|
||||
return rootGroup
|
||||
}
|
||||
maxOrder += 1
|
||||
const group = createProjectGroup({
|
||||
...input,
|
||||
tabOrder: maxOrder
|
||||
})
|
||||
groups.push(group)
|
||||
changed = true
|
||||
return group
|
||||
}
|
||||
})
|
||||
const nextOrderByGroupId = new Map<string, number>()
|
||||
for (const repo of repoCandidates) {
|
||||
const group = resolver.getGroupForRepo(repo.path)
|
||||
if (!group) {
|
||||
continue
|
||||
}
|
||||
const nextOrder = nextOrderByGroupId.get(group.id) ?? 0
|
||||
nextOrderByGroupId.set(group.id, nextOrder + 1)
|
||||
if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) {
|
||||
repo.projectGroupId = group.id
|
||||
repo.projectGroupOrder = nextOrder
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// Why (issue #1158): debounced writes fire as often as every 300ms during
|
||||
// active use. The backup ring should capture meaningfully different moments,
|
||||
// not five near-identical snapshots from one burst of store updates.
|
||||
|
|
@ -1995,13 +2247,18 @@ export class Store {
|
|||
if (!parsed.onboarding) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups)
|
||||
result = {
|
||||
...defaults,
|
||||
...parsed,
|
||||
featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets(
|
||||
parsed.featureInteractionTelemetryBuckets
|
||||
),
|
||||
projectGroups: normalizeProjectGroups(parsed.projectGroups),
|
||||
projectGroups: normalizedProjectGroups,
|
||||
folderWorkspaces: normalizeFolderWorkspaces(
|
||||
parsed.folderWorkspaces,
|
||||
normalizedProjectGroups
|
||||
),
|
||||
worktreeLineageById: parsed.worktreeLineageById ?? {},
|
||||
settings: {
|
||||
...defaults.settings,
|
||||
|
|
@ -2313,11 +2570,15 @@ export class Store {
|
|||
this.loadNeedsSave = true
|
||||
}
|
||||
|
||||
result = {
|
||||
const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({
|
||||
...result,
|
||||
repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []),
|
||||
workspaceSession: migratedScrollback.session
|
||||
})
|
||||
if (folderScopeConnectionMigration.changed) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
result = folderScopeConnectionMigration.state
|
||||
|
||||
return this.migrateTelemetry(result, fileExistedOnLoad)
|
||||
}
|
||||
|
|
@ -2555,6 +2816,7 @@ export class Store {
|
|||
createProjectGroup(input: {
|
||||
name: string
|
||||
parentPath?: string | null
|
||||
connectionId?: string | null
|
||||
parentGroupId?: string | null
|
||||
createdFrom: ProjectGroup['createdFrom']
|
||||
}): ProjectGroup {
|
||||
|
|
@ -2613,6 +2875,165 @@ export class Store {
|
|||
? { ...repo, projectGroupId: null }
|
||||
: repo
|
||||
)
|
||||
for (const workspace of this.state.folderWorkspaces ?? []) {
|
||||
if (deletedGroupIds.has(workspace.projectGroupId)) {
|
||||
this.state.workspaceSession = removeWorkspaceSessionOwner(
|
||||
this.state.workspaceSession,
|
||||
folderWorkspaceKey(workspace.id)
|
||||
)!
|
||||
}
|
||||
}
|
||||
this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter(
|
||||
(workspace) => !deletedGroupIds.has(workspace.projectGroupId)
|
||||
)
|
||||
this.scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
getFolderWorkspaces(): FolderWorkspace[] {
|
||||
return [...(this.state.folderWorkspaces ?? [])].sort(
|
||||
(left, right) => right.sortOrder - left.sortOrder || left.name.localeCompare(right.name)
|
||||
)
|
||||
}
|
||||
|
||||
getFolderWorkspace(id: string): FolderWorkspace | undefined {
|
||||
return (this.state.folderWorkspaces ?? []).find((workspace) => workspace.id === id)
|
||||
}
|
||||
|
||||
createFolderWorkspace(input: {
|
||||
projectGroupId: string
|
||||
name?: string
|
||||
folderPath?: string | null
|
||||
linkedTask?: FolderWorkspace['linkedTask']
|
||||
connectionId?: string | null
|
||||
createdWithAgent?: FolderWorkspace['createdWithAgent']
|
||||
pendingFirstAgentMessageRename?: boolean
|
||||
}): FolderWorkspace {
|
||||
const group = (this.state.projectGroups ?? []).find(
|
||||
(entry) => entry.id === input.projectGroupId
|
||||
)
|
||||
const folderPath =
|
||||
typeof input.folderPath === 'string' && input.folderPath.trim().length > 0
|
||||
? input.folderPath
|
||||
: group?.parentPath
|
||||
if (!group || !folderPath) {
|
||||
throw new Error('Folder-backed project group not found.')
|
||||
}
|
||||
const now = Date.now()
|
||||
const workspace: FolderWorkspace = {
|
||||
id: randomUUID(),
|
||||
projectGroupId: group.id,
|
||||
name: normalizeFolderWorkspaceName(input.name, `${group.name} workspace`),
|
||||
folderPath,
|
||||
connectionId: input.connectionId ?? group.connectionId ?? null,
|
||||
linkedTask: input.linkedTask ?? null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: now,
|
||||
...(input.createdWithAgent ? { createdWithAgent: input.createdWithAgent } : {}),
|
||||
...(input.pendingFirstAgentMessageRename === true && input.createdWithAgent
|
||||
? { pendingFirstAgentMessageRename: true }
|
||||
: {}),
|
||||
lastActivityAt: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
this.state.folderWorkspaces = [workspace, ...(this.state.folderWorkspaces ?? [])]
|
||||
this.scheduleSave()
|
||||
return workspace
|
||||
}
|
||||
|
||||
updateFolderWorkspace(
|
||||
id: string,
|
||||
updates: Partial<
|
||||
Pick<
|
||||
FolderWorkspace,
|
||||
| 'name'
|
||||
| 'folderPath'
|
||||
| 'linkedTask'
|
||||
| 'comment'
|
||||
| 'isArchived'
|
||||
| 'isUnread'
|
||||
| 'isPinned'
|
||||
| 'sortOrder'
|
||||
| 'manualOrder'
|
||||
| 'workspaceStatus'
|
||||
| 'createdWithAgent'
|
||||
| 'pendingFirstAgentMessageRename'
|
||||
| 'firstAgentMessageRenameError'
|
||||
| 'lastActivityAt'
|
||||
>
|
||||
>
|
||||
): FolderWorkspace | null {
|
||||
const workspace = this.getFolderWorkspace(id)
|
||||
if (!workspace) {
|
||||
return null
|
||||
}
|
||||
if (updates.name !== undefined) {
|
||||
workspace.name = normalizeFolderWorkspaceName(updates.name, workspace.name)
|
||||
}
|
||||
if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) {
|
||||
workspace.folderPath = updates.folderPath
|
||||
}
|
||||
if (updates.linkedTask !== undefined) {
|
||||
workspace.linkedTask = updates.linkedTask
|
||||
}
|
||||
if (updates.comment !== undefined) {
|
||||
workspace.comment = updates.comment
|
||||
}
|
||||
if (updates.isArchived !== undefined) {
|
||||
workspace.isArchived = updates.isArchived
|
||||
}
|
||||
if (updates.isUnread !== undefined) {
|
||||
workspace.isUnread = updates.isUnread
|
||||
}
|
||||
if (updates.isPinned !== undefined) {
|
||||
workspace.isPinned = updates.isPinned
|
||||
}
|
||||
if (updates.sortOrder !== undefined && Number.isFinite(updates.sortOrder)) {
|
||||
workspace.sortOrder = updates.sortOrder
|
||||
}
|
||||
if (updates.manualOrder !== undefined) {
|
||||
if (Number.isFinite(updates.manualOrder)) {
|
||||
workspace.manualOrder = updates.manualOrder
|
||||
} else {
|
||||
delete workspace.manualOrder
|
||||
}
|
||||
}
|
||||
if (updates.workspaceStatus !== undefined) {
|
||||
workspace.workspaceStatus = updates.workspaceStatus
|
||||
}
|
||||
if (updates.createdWithAgent !== undefined) {
|
||||
workspace.createdWithAgent = updates.createdWithAgent
|
||||
}
|
||||
if (updates.pendingFirstAgentMessageRename !== undefined) {
|
||||
workspace.pendingFirstAgentMessageRename = updates.pendingFirstAgentMessageRename
|
||||
}
|
||||
if (updates.firstAgentMessageRenameError !== undefined) {
|
||||
workspace.firstAgentMessageRenameError = updates.firstAgentMessageRenameError
|
||||
}
|
||||
if (updates.lastActivityAt !== undefined && Number.isFinite(updates.lastActivityAt)) {
|
||||
workspace.lastActivityAt = updates.lastActivityAt
|
||||
}
|
||||
workspace.updatedAt = Date.now()
|
||||
this.scheduleSave()
|
||||
return workspace
|
||||
}
|
||||
|
||||
removeFolderWorkspace(id: string): boolean {
|
||||
const before = this.state.folderWorkspaces?.length ?? 0
|
||||
this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter(
|
||||
(workspace) => workspace.id !== id
|
||||
)
|
||||
if ((this.state.folderWorkspaces?.length ?? 0) === before) {
|
||||
return false
|
||||
}
|
||||
this.state.workspaceSession = removeWorkspaceSessionOwner(
|
||||
this.state.workspaceSession,
|
||||
folderWorkspaceKey(id)
|
||||
)!
|
||||
this.scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getFolderWorkspacePathStatusForPath,
|
||||
inferFolderWorkspacePathConnection
|
||||
} from './folder-workspace-path-status'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import type { ProjectGroup, Repo } from '../../shared/types'
|
||||
|
||||
function makeGroup(overrides: Partial<ProjectGroup> = {}): ProjectGroup {
|
||||
return {
|
||||
id: 'group-1',
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/workspace/platform/api',
|
||||
displayName: 'api',
|
||||
badgeColor: 'gray',
|
||||
addedAt: 1,
|
||||
projectGroupId: 'group-1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('folder workspace path status', () => {
|
||||
it('reports existing local directories and local files', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-folder-status-'))
|
||||
try {
|
||||
const filePath = join(root, 'notes.txt')
|
||||
await writeFile(filePath, 'hello')
|
||||
|
||||
await expect(
|
||||
getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath: root,
|
||||
projectGroupId: 'group-1',
|
||||
projectGroups: [makeGroup({ parentPath: root })],
|
||||
repos: []
|
||||
},
|
||||
{ getSshFilesystemProvider: () => undefined }
|
||||
)
|
||||
).resolves.toEqual({ path: root, exists: true })
|
||||
|
||||
await expect(
|
||||
getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath: filePath,
|
||||
projectGroupId: 'group-1',
|
||||
projectGroups: [makeGroup({ parentPath: filePath })],
|
||||
repos: []
|
||||
},
|
||||
{ getSshFilesystemProvider: () => undefined }
|
||||
)
|
||||
).resolves.toEqual({ path: filePath, exists: false, reason: 'not-directory' })
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports missing local directories', async () => {
|
||||
const missingPath = join(tmpdir(), `orca-folder-status-missing-${randomUUID()}`)
|
||||
|
||||
await expect(
|
||||
getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath: missingPath,
|
||||
projectGroupId: 'group-1',
|
||||
projectGroups: [makeGroup({ parentPath: missingPath })],
|
||||
repos: []
|
||||
},
|
||||
{ getSshFilesystemProvider: () => undefined }
|
||||
)
|
||||
).resolves.toEqual({ path: missingPath, exists: false, reason: 'missing' })
|
||||
})
|
||||
|
||||
it('routes inferred SSH folder scopes through the SSH filesystem provider', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 })
|
||||
} as unknown as IFilesystemProvider
|
||||
|
||||
await expect(
|
||||
getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath: '/workspace/platform',
|
||||
projectGroupId: 'group-1',
|
||||
projectGroups: [makeGroup()],
|
||||
repos: [makeRepo({ connectionId: 'ssh-1' })]
|
||||
},
|
||||
{ getSshFilesystemProvider: () => provider }
|
||||
)
|
||||
).resolves.toEqual({ path: '/workspace/platform', exists: true })
|
||||
expect(provider.stat).toHaveBeenCalledWith('/workspace/platform')
|
||||
})
|
||||
|
||||
it('routes explicit SSH folder scopes through SSH without child repos', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 })
|
||||
} as unknown as IFilesystemProvider
|
||||
|
||||
await expect(
|
||||
getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath: '/workspace/platform',
|
||||
projectGroupId: 'group-1',
|
||||
connectionId: 'ssh-1',
|
||||
projectGroups: [makeGroup({ connectionId: 'ssh-1' })],
|
||||
repos: []
|
||||
},
|
||||
{ getSshFilesystemProvider: () => provider }
|
||||
)
|
||||
).resolves.toEqual({ path: '/workspace/platform', exists: true })
|
||||
expect(provider.stat).toHaveBeenCalledWith('/workspace/platform')
|
||||
})
|
||||
|
||||
it('reports unavailable when an inferred SSH provider is missing', async () => {
|
||||
await expect(
|
||||
getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath: '/workspace/platform',
|
||||
projectGroupId: 'group-1',
|
||||
projectGroups: [makeGroup()],
|
||||
repos: [makeRepo({ connectionId: 'ssh-1' })]
|
||||
},
|
||||
{ getSshFilesystemProvider: () => undefined }
|
||||
)
|
||||
).resolves.toEqual({ path: '/workspace/platform', exists: false, reason: 'unavailable' })
|
||||
})
|
||||
|
||||
it('reports ambiguous connection for mixed SSH scopes', () => {
|
||||
expect(
|
||||
inferFolderWorkspacePathConnection({
|
||||
folderPath: '/workspace/platform',
|
||||
projectGroupId: 'group-1',
|
||||
projectGroups: [makeGroup()],
|
||||
repos: [
|
||||
makeRepo({ id: 'repo-1', connectionId: 'ssh-1' }),
|
||||
makeRepo({ id: 'repo-2', connectionId: 'ssh-2' })
|
||||
]
|
||||
})
|
||||
).toEqual({ kind: 'ambiguous' })
|
||||
})
|
||||
|
||||
it('reports ambiguous connection for mixed local and SSH scopes', () => {
|
||||
expect(
|
||||
inferFolderWorkspacePathConnection({
|
||||
folderPath: '/workspace/platform',
|
||||
projectGroupId: 'group-1',
|
||||
projectGroups: [makeGroup()],
|
||||
repos: [
|
||||
makeRepo({ id: 'repo-1', connectionId: undefined }),
|
||||
makeRepo({ id: 'repo-2', connectionId: 'ssh-1' })
|
||||
]
|
||||
})
|
||||
).toEqual({ kind: 'ambiguous' })
|
||||
})
|
||||
|
||||
it('reports ambiguous connection when explicit SSH scope conflicts with repos', () => {
|
||||
expect(
|
||||
inferFolderWorkspacePathConnection({
|
||||
folderPath: '/workspace/platform',
|
||||
projectGroupId: 'group-1',
|
||||
connectionId: 'ssh-1',
|
||||
projectGroups: [makeGroup({ connectionId: 'ssh-1' })],
|
||||
repos: [
|
||||
makeRepo({ id: 'repo-1', connectionId: 'ssh-1' }),
|
||||
makeRepo({ id: 'repo-2', connectionId: 'ssh-2' })
|
||||
]
|
||||
})
|
||||
).toEqual({ kind: 'ambiguous' })
|
||||
})
|
||||
|
||||
it('keeps explicit SSH scopes isolated from unrelated same-path SSH repos', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 })
|
||||
} as unknown as IFilesystemProvider
|
||||
|
||||
await expect(
|
||||
getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath: '/workspace/platform',
|
||||
projectGroupId: 'group-1',
|
||||
connectionId: 'ssh-1',
|
||||
projectGroups: [
|
||||
makeGroup({ id: 'group-1', connectionId: 'ssh-1' }),
|
||||
makeGroup({ id: 'group-2', connectionId: 'ssh-2' })
|
||||
],
|
||||
repos: [
|
||||
makeRepo({ id: 'repo-1', path: '/workspace/platform/api', connectionId: 'ssh-1' }),
|
||||
makeRepo({
|
||||
id: 'repo-2',
|
||||
path: '/workspace/platform/api',
|
||||
projectGroupId: 'group-2',
|
||||
connectionId: 'ssh-2'
|
||||
})
|
||||
]
|
||||
},
|
||||
{
|
||||
getSshFilesystemProvider: (connectionId) =>
|
||||
connectionId === 'ssh-1' ? provider : undefined
|
||||
}
|
||||
)
|
||||
).resolves.toEqual({ path: '/workspace/platform', exists: true })
|
||||
expect(provider.stat).toHaveBeenCalledWith('/workspace/platform')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import { stat as statLocalPath } from 'fs/promises'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import type {
|
||||
FolderWorkspacePathStatus,
|
||||
FolderWorkspacePathStatusRequest
|
||||
} from '../../shared/folder-workspace-path-status'
|
||||
import { getProjectGroupSubtreeIds } from '../../shared/project-groups'
|
||||
import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
|
||||
type FolderWorkspacePathStatusStore = {
|
||||
getRepos: () => Repo[]
|
||||
getProjectGroups?: () => ProjectGroup[]
|
||||
getFolderWorkspaces?: () => FolderWorkspace[]
|
||||
}
|
||||
|
||||
export type FolderWorkspacePathConnectionResolution =
|
||||
| { kind: 'local' }
|
||||
| { kind: 'ssh'; connectionId: string }
|
||||
| { kind: 'ambiguous' }
|
||||
|
||||
type FolderWorkspacePathStatusDeps = {
|
||||
getSshFilesystemProvider: (connectionId: string) => IFilesystemProvider | undefined
|
||||
}
|
||||
|
||||
function getFolderScopeCandidateRepos(args: {
|
||||
folderPath: string
|
||||
projectGroupId: string
|
||||
connectionId?: string | null
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
repos: readonly Repo[]
|
||||
}): Repo[] {
|
||||
const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
|
||||
const groupRepos = args.repos.filter(
|
||||
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
|
||||
)
|
||||
const pathRepos = args.repos.filter(
|
||||
(repo) =>
|
||||
!(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) &&
|
||||
isPathInsideOrEqual(args.folderPath, repo.path)
|
||||
)
|
||||
if (args.connectionId) {
|
||||
return [
|
||||
...groupRepos,
|
||||
...pathRepos.filter((repo) => (repo.connectionId ?? null) === args.connectionId)
|
||||
]
|
||||
}
|
||||
if (groupRepos.length === 0) {
|
||||
return pathRepos
|
||||
}
|
||||
const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null))
|
||||
return [
|
||||
...groupRepos,
|
||||
...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null))
|
||||
]
|
||||
}
|
||||
|
||||
export function inferFolderWorkspacePathConnection(args: {
|
||||
folderPath: string
|
||||
projectGroupId: string
|
||||
connectionId?: string | null
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
repos: readonly Repo[]
|
||||
}): FolderWorkspacePathConnectionResolution {
|
||||
const candidateRepos = getFolderScopeCandidateRepos(args)
|
||||
let hasLocalRepo = false
|
||||
const connectionIds = new Set<string>()
|
||||
for (const repo of candidateRepos) {
|
||||
if (repo.connectionId) {
|
||||
connectionIds.add(repo.connectionId)
|
||||
} else {
|
||||
hasLocalRepo = true
|
||||
}
|
||||
}
|
||||
if (args.connectionId) {
|
||||
const hasDifferentSshConnection = [...connectionIds].some(
|
||||
(connectionId) => connectionId !== args.connectionId
|
||||
)
|
||||
if (hasLocalRepo || hasDifferentSshConnection) {
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
return { kind: 'ssh', connectionId: args.connectionId }
|
||||
}
|
||||
if (hasLocalRepo && connectionIds.size > 0) {
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
if (connectionIds.size === 0) {
|
||||
return { kind: 'local' }
|
||||
}
|
||||
if (connectionIds.size === 1) {
|
||||
return { kind: 'ssh', connectionId: [...connectionIds][0] }
|
||||
}
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
|
||||
function pathStatErrorReason(error: unknown): 'missing' | 'unavailable' {
|
||||
const code = (error as { code?: unknown } | null)?.code
|
||||
return code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unavailable'
|
||||
}
|
||||
|
||||
async function statFolderPath(
|
||||
path: string,
|
||||
connection: FolderWorkspacePathConnectionResolution,
|
||||
deps: FolderWorkspacePathStatusDeps
|
||||
): Promise<FolderWorkspacePathStatus> {
|
||||
if (connection.kind === 'ambiguous') {
|
||||
return { path, exists: false, reason: 'ambiguous-connection' }
|
||||
}
|
||||
if (connection.kind === 'ssh') {
|
||||
const provider = deps.getSshFilesystemProvider(connection.connectionId)
|
||||
if (!provider) {
|
||||
return { path, exists: false, reason: 'unavailable' }
|
||||
}
|
||||
try {
|
||||
const stats = await provider.stat(path)
|
||||
return stats.type === 'directory'
|
||||
? { path, exists: true }
|
||||
: { path, exists: false, reason: 'not-directory' }
|
||||
} catch (error) {
|
||||
return { path, exists: false, reason: pathStatErrorReason(error) }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await statLocalPath(path)
|
||||
return stats.isDirectory()
|
||||
? { path, exists: true }
|
||||
: { path, exists: false, reason: 'not-directory' }
|
||||
} catch (error) {
|
||||
return { path, exists: false, reason: pathStatErrorReason(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFolderWorkspacePathStatusForPath(
|
||||
args: {
|
||||
folderPath: string
|
||||
projectGroupId: string
|
||||
connectionId?: string | null
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
repos: readonly Repo[]
|
||||
},
|
||||
deps: FolderWorkspacePathStatusDeps
|
||||
): Promise<FolderWorkspacePathStatus> {
|
||||
const connection = inferFolderWorkspacePathConnection(args)
|
||||
return statFolderPath(args.folderPath, connection, deps)
|
||||
}
|
||||
|
||||
export function resolveFolderWorkspaceStatusPath(args: {
|
||||
store: FolderWorkspacePathStatusStore
|
||||
request: FolderWorkspacePathStatusRequest
|
||||
}): { folderPath: string; projectGroupId: string; connectionId?: string | null } {
|
||||
const { request } = args
|
||||
if (request.scope === 'project-group') {
|
||||
const group = args.store
|
||||
.getProjectGroups?.()
|
||||
.find((entry) => entry.id === request.projectGroupId)
|
||||
if (!group?.parentPath) {
|
||||
throw new Error('folder_workspace_path_scope_not_found')
|
||||
}
|
||||
return {
|
||||
folderPath: group.parentPath,
|
||||
projectGroupId: group.id,
|
||||
connectionId: group.connectionId ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const workspace = args.store
|
||||
.getFolderWorkspaces?.()
|
||||
.find((entry) => entry.id === request.folderWorkspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error('folder_workspace_path_scope_not_found')
|
||||
}
|
||||
const group = args.store
|
||||
.getProjectGroups?.()
|
||||
.find((entry) => entry.id === workspace.projectGroupId)
|
||||
return {
|
||||
folderPath: workspace.folderPath,
|
||||
projectGroupId: workspace.projectGroupId,
|
||||
connectionId: workspace.connectionId ?? group?.connectionId ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFolderWorkspacePathStatus(
|
||||
store: FolderWorkspacePathStatusStore,
|
||||
request: FolderWorkspacePathStatusRequest,
|
||||
deps: FolderWorkspacePathStatusDeps
|
||||
): Promise<FolderWorkspacePathStatus> {
|
||||
const scope = resolveFolderWorkspaceStatusPath({ store, request })
|
||||
return getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath: scope.folderPath,
|
||||
projectGroupId: scope.projectGroupId,
|
||||
connectionId: scope.connectionId,
|
||||
projectGroups: store.getProjectGroups?.() ?? [],
|
||||
repos: store.getRepos()
|
||||
},
|
||||
deps
|
||||
)
|
||||
}
|
||||
|
||||
export function assertFolderWorkspacePathUsable(status: FolderWorkspacePathStatus): void {
|
||||
if (status.exists) {
|
||||
return
|
||||
}
|
||||
if (status.reason === 'missing') {
|
||||
throw new Error(`folder_workspace_path_missing:${status.path}`)
|
||||
}
|
||||
if (status.reason === 'not-directory') {
|
||||
throw new Error(`folder_workspace_path_not_directory:${status.path}`)
|
||||
}
|
||||
if (status.reason === 'ambiguous-connection') {
|
||||
throw new Error(`folder_workspace_connection_ambiguous:${status.path}`)
|
||||
}
|
||||
throw new Error(`folder_workspace_path_unavailable:${status.path}`)
|
||||
}
|
||||
|
|
@ -6,18 +6,57 @@ import {
|
|||
} from './nested-repo-import'
|
||||
import type { ProjectGroup } from '../../shared/types'
|
||||
|
||||
function createGroupRecorder(): {
|
||||
groups: ProjectGroup[]
|
||||
createGroup: (input: {
|
||||
name: string
|
||||
parentPath?: string | null
|
||||
connectionId?: string | null
|
||||
parentGroupId?: string | null
|
||||
createdFrom: ProjectGroup['createdFrom']
|
||||
}) => ProjectGroup
|
||||
} {
|
||||
const groups: ProjectGroup[] = []
|
||||
return {
|
||||
groups,
|
||||
createGroup: (input) => {
|
||||
const group: ProjectGroup = {
|
||||
id: `group-${groups.length}`,
|
||||
name: input.name,
|
||||
parentPath: input.parentPath ?? null,
|
||||
connectionId: input.connectionId ?? null,
|
||||
parentGroupId: input.parentGroupId ?? null,
|
||||
createdFrom: input.createdFrom,
|
||||
tabOrder: groups.length,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
groups.push(group)
|
||||
return group
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('createNestedProjectGroupResolver', () => {
|
||||
it('creates one root group for nested repos in grouped imports', () => {
|
||||
it('creates sparse folder scopes for nested repos in grouped imports', () => {
|
||||
const groups: ProjectGroup[] = []
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace',
|
||||
groupName: 'workspace',
|
||||
mode: 'group',
|
||||
repoPaths: [
|
||||
'/workspace/gateway-api',
|
||||
'/workspace/services/payments/api',
|
||||
'/workspace/services/payments/worker'
|
||||
],
|
||||
createGroup: (input) => {
|
||||
const group: ProjectGroup = {
|
||||
id: `group-${groups.length}`,
|
||||
name: input.name,
|
||||
parentPath: input.parentPath ?? null,
|
||||
connectionId: input.connectionId ?? null,
|
||||
parentGroupId: input.parentGroupId ?? null,
|
||||
createdFrom: input.createdFrom,
|
||||
tabOrder: groups.length,
|
||||
|
|
@ -36,17 +75,70 @@ describe('createNestedProjectGroupResolver', () => {
|
|||
const sibling = resolver.getGroupForRepo('/workspace/services/payments/worker')
|
||||
|
||||
expect(direct?.name).toBe('workspace')
|
||||
expect(nested?.name).toBe('workspace')
|
||||
expect(nested?.name).toBe('services/payments')
|
||||
expect(sibling?.id).toBe(nested?.id)
|
||||
expect(groups.map((group) => [group.name, group.parentGroupId])).toEqual([['workspace', null]])
|
||||
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
|
||||
['workspace', null, '/workspace'],
|
||||
['services/payments', 'group-0', '/workspace/services/payments']
|
||||
])
|
||||
expect(resolver.getRootGroup()?.id).toBe('group-0')
|
||||
})
|
||||
|
||||
it('skips intermediate folders that only lead to one meaningful child scope', () => {
|
||||
const { groups, createGroup } = createGroupRecorder()
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace/platform',
|
||||
groupName: 'Platform',
|
||||
mode: 'group',
|
||||
repoPaths: [
|
||||
'/workspace/platform/api',
|
||||
'/workspace/platform/web',
|
||||
'/workspace/platform/packages/shared/repo1',
|
||||
'/workspace/platform/packages/shared/repo2'
|
||||
],
|
||||
createGroup
|
||||
})
|
||||
|
||||
const api = resolver.getGroupForRepo('/workspace/platform/api')
|
||||
const repo1 = resolver.getGroupForRepo('/workspace/platform/packages/shared/repo1')
|
||||
const repo2 = resolver.getGroupForRepo('/workspace/platform/packages/shared/repo2')
|
||||
|
||||
expect(api?.name).toBe('Platform')
|
||||
expect(repo1?.name).toBe('packages/shared')
|
||||
expect(repo2?.id).toBe(repo1?.id)
|
||||
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
|
||||
['Platform', null, '/workspace/platform'],
|
||||
['packages/shared', 'group-0', '/workspace/platform/packages/shared']
|
||||
])
|
||||
})
|
||||
|
||||
it('creates a parent folder scope when it has direct repos and nested descendants', () => {
|
||||
const { groups, createGroup } = createGroupRecorder()
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace/platform',
|
||||
groupName: 'Platform',
|
||||
mode: 'group',
|
||||
repoPaths: ['/workspace/platform/services/api', '/workspace/platform/services/jobs/worker'],
|
||||
createGroup
|
||||
})
|
||||
|
||||
const direct = resolver.getGroupForRepo('/workspace/platform/services/api')
|
||||
const nested = resolver.getGroupForRepo('/workspace/platform/services/jobs/worker')
|
||||
|
||||
expect(direct?.name).toBe('services')
|
||||
expect(nested?.id).toBe(direct?.id)
|
||||
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
|
||||
['Platform', null, '/workspace/platform'],
|
||||
['services', 'group-0', '/workspace/platform/services']
|
||||
])
|
||||
})
|
||||
|
||||
it('does not create groups for separate imports', () => {
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace',
|
||||
groupName: 'workspace',
|
||||
mode: 'separate',
|
||||
repoPaths: ['/workspace/services/api', '/workspace/services/worker'],
|
||||
createGroup: () => {
|
||||
throw new Error('should not create a group')
|
||||
}
|
||||
|
|
@ -62,6 +154,7 @@ describe('createNestedProjectGroupResolver', () => {
|
|||
parentPath: '/',
|
||||
groupName: 'root',
|
||||
mode: 'group',
|
||||
repoPaths: ['/api', '/services/api'],
|
||||
createGroup: (input) => {
|
||||
const group: ProjectGroup = {
|
||||
id: `group-${groups.length}`,
|
||||
|
|
@ -92,6 +185,7 @@ describe('createNestedProjectGroupResolver', () => {
|
|||
parentPath: 'C:\\',
|
||||
groupName: 'C',
|
||||
mode: 'group',
|
||||
repoPaths: ['C:\\api', 'C:\\services\\api'],
|
||||
createGroup: (input) => {
|
||||
const group: ProjectGroup = {
|
||||
id: `group-${groups.length}`,
|
||||
|
|
@ -116,6 +210,53 @@ describe('createNestedProjectGroupResolver', () => {
|
|||
expect(groups.map((group) => group.parentPath)).toEqual(['C:/'])
|
||||
})
|
||||
|
||||
it('creates sparse folder scopes for Windows repo paths', () => {
|
||||
const { groups, createGroup } = createGroupRecorder()
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: 'C:\\workspace\\platform',
|
||||
groupName: 'Platform',
|
||||
mode: 'group',
|
||||
repoPaths: [
|
||||
'C:\\workspace\\platform\\apps\\web',
|
||||
'C:\\workspace\\platform\\packages\\shared\\repo1',
|
||||
'C:\\workspace\\platform\\packages\\shared\\repo2'
|
||||
],
|
||||
createGroup
|
||||
})
|
||||
|
||||
const web = resolver.getGroupForRepo('C:\\workspace\\platform\\apps\\web')
|
||||
const repo1 = resolver.getGroupForRepo('C:\\workspace\\platform\\packages\\shared\\repo1')
|
||||
|
||||
expect(web?.name).toBe('Platform')
|
||||
expect(repo1?.name).toBe('packages/shared')
|
||||
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
|
||||
['Platform', null, 'C:/workspace/platform'],
|
||||
['packages/shared', 'group-0', 'C:/workspace/platform/packages/shared']
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves SSH provenance on grouped folder scopes', () => {
|
||||
const { groups, createGroup } = createGroupRecorder()
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace/platform',
|
||||
groupName: 'Platform',
|
||||
mode: 'group',
|
||||
connectionId: 'ssh-1',
|
||||
repoPaths: [
|
||||
'/workspace/platform/packages/shared/repo1',
|
||||
'/workspace/platform/packages/shared/repo2'
|
||||
],
|
||||
createGroup
|
||||
})
|
||||
|
||||
resolver.getGroupForRepo('/workspace/platform/packages/shared/repo1')
|
||||
|
||||
expect(groups.map((group) => [group.name, group.connectionId])).toEqual([
|
||||
['Platform', 'ssh-1'],
|
||||
['packages/shared', 'ssh-1']
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the selected parent folder basename for blank group names', () => {
|
||||
const groups: ProjectGroup[] = []
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import {
|
|||
isPathInsideOrEqual,
|
||||
isRuntimePathAbsolute,
|
||||
normalizeRuntimePathForComparison,
|
||||
relativePathInsideRoot,
|
||||
resolveRuntimePath
|
||||
} from '../../shared/cross-platform-path'
|
||||
|
||||
type CreateGroupInput = {
|
||||
name: string
|
||||
parentPath?: string | null
|
||||
connectionId?: string | null
|
||||
parentGroupId?: string | null
|
||||
createdFrom: ProjectGroup['createdFrom']
|
||||
}
|
||||
|
|
@ -25,6 +27,13 @@ export type ResolvedNestedRepoSelection = {
|
|||
rejectedPaths: string[]
|
||||
}
|
||||
|
||||
type FolderScope = {
|
||||
relativePath: string
|
||||
name: string
|
||||
folderPath: string
|
||||
parentRelativePath: string | null
|
||||
}
|
||||
|
||||
function canonicalizeImportPath(path: string): string | null {
|
||||
if (!isRuntimePathAbsolute(path)) {
|
||||
return null
|
||||
|
|
@ -39,16 +48,112 @@ function trimPathSeparators(path: string): string {
|
|||
if (/^\/\/[^/]+\/[^/]+\/?$/.test(path.replace(/\\/g, '/'))) {
|
||||
return path.replace(/\\/g, '/').replace(/\/$/, '')
|
||||
}
|
||||
return path.replace(/[\\/]+$/g, '')
|
||||
return path.replace(/\\/g, '/').replace(/\/+$/g, '')
|
||||
}
|
||||
|
||||
function normalizeRelativePath(value: string): string {
|
||||
return value.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
function getFolderRelativePathForRepo(parentPath: string, repoPath: string): string | null {
|
||||
const relativePath = relativePathInsideRoot(parentPath, repoPath)
|
||||
if (relativePath === null || relativePath === '') {
|
||||
return null
|
||||
}
|
||||
const segments = normalizeRelativePath(relativePath).split('/').filter(Boolean)
|
||||
segments.pop()
|
||||
return segments.join('/')
|
||||
}
|
||||
|
||||
function resolveFolderPath(parentPath: string, relativePath: string): string {
|
||||
return trimPathSeparators(resolveRuntimePath(parentPath, relativePath))
|
||||
}
|
||||
|
||||
function getNearestScopePath(
|
||||
relativePath: string,
|
||||
scopePaths: { has: (value: string) => boolean }
|
||||
): string | null {
|
||||
const segments = normalizeRelativePath(relativePath).split('/').filter(Boolean)
|
||||
for (let length = segments.length; length > 0; length -= 1) {
|
||||
const candidate = segments.slice(0, length).join('/')
|
||||
if (scopePaths.has(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildSparseFolderScopes(args: {
|
||||
parentPath: string
|
||||
repoPaths: readonly string[]
|
||||
}): FolderScope[] {
|
||||
// Why: folder-backed workspaces should expose meaningful launch scopes
|
||||
// without turning every one-child filesystem segment into sidebar structure.
|
||||
const folderStats = new Map<string, { directRepoCount: number; totalRepoCount: number }>()
|
||||
const noteFolder = (relativePath: string, field: 'directRepoCount' | 'totalRepoCount'): void => {
|
||||
const normalized = normalizeRelativePath(relativePath)
|
||||
const stats = folderStats.get(normalized) ?? { directRepoCount: 0, totalRepoCount: 0 }
|
||||
stats[field] += 1
|
||||
folderStats.set(normalized, stats)
|
||||
}
|
||||
|
||||
for (const repoPath of args.repoPaths) {
|
||||
const folderRelativePath = getFolderRelativePathForRepo(args.parentPath, repoPath)
|
||||
if (folderRelativePath === null) {
|
||||
continue
|
||||
}
|
||||
noteFolder(folderRelativePath, 'directRepoCount')
|
||||
const segments = folderRelativePath.split('/').filter(Boolean)
|
||||
for (let length = 1; length <= segments.length; length += 1) {
|
||||
noteFolder(segments.slice(0, length).join('/'), 'totalRepoCount')
|
||||
}
|
||||
}
|
||||
|
||||
const meaningfulPaths = [...folderStats.entries()]
|
||||
.filter(([relativePath, stats]) => {
|
||||
if (!relativePath) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
stats.directRepoCount >= 2 ||
|
||||
(stats.directRepoCount > 0 && stats.totalRepoCount > stats.directRepoCount)
|
||||
)
|
||||
})
|
||||
.map(([relativePath]) => relativePath)
|
||||
.sort(
|
||||
(left, right) => left.split('/').length - right.split('/').length || left.localeCompare(right)
|
||||
)
|
||||
const meaningfulPathSet = new Set(meaningfulPaths)
|
||||
|
||||
return meaningfulPaths.map((relativePath) => {
|
||||
const parentRelativePath =
|
||||
getNearestScopePath(relativePath.split('/').slice(0, -1).join('/'), meaningfulPathSet) ?? null
|
||||
return {
|
||||
relativePath,
|
||||
name: relativePath,
|
||||
folderPath: resolveFolderPath(args.parentPath, relativePath),
|
||||
parentRelativePath
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createNestedProjectGroupResolver(args: {
|
||||
parentPath: string
|
||||
groupName: string
|
||||
mode: ProjectGroupImportMode
|
||||
connectionId?: string | null
|
||||
repoPaths?: readonly string[]
|
||||
createGroup: (input: CreateGroupInput) => ProjectGroup
|
||||
}): NestedProjectGroupResolver {
|
||||
const createdGroups: ProjectGroup[] = []
|
||||
const folderScopes = buildSparseFolderScopes({
|
||||
parentPath: args.parentPath,
|
||||
repoPaths: args.repoPaths ?? []
|
||||
})
|
||||
const folderScopesByRelativePath = new Map(
|
||||
folderScopes.map((scope) => [scope.relativePath, scope])
|
||||
)
|
||||
const folderScopeGroups = new Map<string, ProjectGroup>()
|
||||
let rootGroup: ProjectGroup | undefined
|
||||
|
||||
const ensureRootGroup = (): ProjectGroup | undefined => {
|
||||
|
|
@ -62,6 +167,7 @@ export function createNestedProjectGroupResolver(args: {
|
|||
rootGroup = args.createGroup({
|
||||
name: args.groupName.trim() || fallbackName,
|
||||
parentPath: trimPathSeparators(args.parentPath),
|
||||
connectionId: args.connectionId ?? null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
|
|
@ -69,8 +175,46 @@ export function createNestedProjectGroupResolver(args: {
|
|||
return rootGroup
|
||||
}
|
||||
|
||||
const ensureFolderScopeGroup = (relativePath: string): ProjectGroup | undefined => {
|
||||
const root = ensureRootGroup()
|
||||
if (!root) {
|
||||
return undefined
|
||||
}
|
||||
const existing = folderScopeGroups.get(relativePath)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const scope = folderScopesByRelativePath.get(relativePath)
|
||||
if (!scope) {
|
||||
return root
|
||||
}
|
||||
const parentGroup = scope.parentRelativePath
|
||||
? ensureFolderScopeGroup(scope.parentRelativePath)
|
||||
: root
|
||||
const group = args.createGroup({
|
||||
name: scope.name,
|
||||
parentPath: scope.folderPath,
|
||||
connectionId: args.connectionId ?? null,
|
||||
parentGroupId: parentGroup?.id ?? root.id,
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
folderScopeGroups.set(relativePath, group)
|
||||
createdGroups.push(group)
|
||||
return group
|
||||
}
|
||||
|
||||
return {
|
||||
getGroupForRepo: () => ensureRootGroup(),
|
||||
getGroupForRepo: (repoPath) => {
|
||||
const root = ensureRootGroup()
|
||||
if (!root) {
|
||||
return undefined
|
||||
}
|
||||
const folderRelativePath = getFolderRelativePathForRepo(args.parentPath, repoPath)
|
||||
const scopePath = folderRelativePath
|
||||
? getNearestScopePath(folderRelativePath, folderScopesByRelativePath)
|
||||
: null
|
||||
return scopePath ? ensureFolderScopeGroup(scopePath) : root
|
||||
},
|
||||
getRootGroup: () => rootGroup,
|
||||
getCreatedGroups: () => [...createdGroups]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
/* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EventEmitter } from 'events'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { ipcMain } from 'electron'
|
||||
import type {
|
||||
FolderWorkspace,
|
||||
ProjectGroup,
|
||||
TerminalLayoutSnapshot,
|
||||
WorktreeLineage,
|
||||
WorktreeMeta,
|
||||
|
|
@ -506,6 +509,10 @@ const TEST_REPO_ID = 'repo-1'
|
|||
const TEST_REPO_PATH = '/tmp/repo'
|
||||
const TEST_WORKTREE_PATH = '/tmp/worktree-a'
|
||||
const TEST_WORKTREE_ID = `${TEST_REPO_ID}::${TEST_WORKTREE_PATH}`
|
||||
const TEST_FOLDER_PROJECT_GROUP_ID = 'folder-project-group-1'
|
||||
const TEST_FOLDER_WORKSPACE_ID = 'folder-workspace-1'
|
||||
const TEST_FOLDER_WORKSPACE_KEY = `folder:${TEST_FOLDER_WORKSPACE_ID}`
|
||||
const TEST_FOLDER_WORKSPACE_PATH = '/tmp/platform'
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
const HEADLESS_LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const HEADLESS_SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
|
@ -623,6 +630,52 @@ function createRuntime(): OrcaRuntimeService {
|
|||
return new OrcaRuntimeService(store)
|
||||
}
|
||||
|
||||
function makeFolderProjectGroup(overrides: Partial<ProjectGroup> = {}): ProjectGroup {
|
||||
return {
|
||||
id: TEST_FOLDER_PROJECT_GROUP_ID,
|
||||
name: 'Platform',
|
||||
parentPath: TEST_FOLDER_WORKSPACE_PATH,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
|
||||
return {
|
||||
...overrides,
|
||||
id: overrides.id ?? TEST_FOLDER_WORKSPACE_ID,
|
||||
projectGroupId: overrides.projectGroupId ?? TEST_FOLDER_PROJECT_GROUP_ID,
|
||||
name: overrides.name ?? 'Refund fix',
|
||||
folderPath: overrides.folderPath ?? TEST_FOLDER_WORKSPACE_PATH,
|
||||
linkedTask: overrides.linkedTask ?? null,
|
||||
comment: overrides.comment ?? '',
|
||||
isArchived: overrides.isArchived ?? false,
|
||||
isUnread: overrides.isUnread ?? false,
|
||||
isPinned: overrides.isPinned ?? false,
|
||||
sortOrder: overrides.sortOrder ?? 0,
|
||||
lastActivityAt: overrides.lastActivityAt ?? 1,
|
||||
createdAt: overrides.createdAt ?? 1,
|
||||
updatedAt: overrides.updatedAt ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
function createFolderWorkspaceRuntimeStore(
|
||||
folderWorkspace: FolderWorkspace = makeFolderWorkspace(),
|
||||
projectGroup: ProjectGroup = makeFolderProjectGroup()
|
||||
) {
|
||||
return {
|
||||
...store,
|
||||
getProjectGroups: () => [projectGroup],
|
||||
getFolderWorkspaces: () => [folderWorkspace]
|
||||
}
|
||||
}
|
||||
|
||||
function makeRpcRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
|
@ -3952,6 +4005,86 @@ describe('OrcaRuntimeService', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'canonical folder workspace selector', selector: TEST_FOLDER_WORKSPACE_KEY },
|
||||
{ label: 'id-prefixed folder workspace selector', selector: `id:${TEST_FOLDER_WORKSPACE_KEY}` }
|
||||
])('creates background terminal sessions for a $label', async ({ selector }) => {
|
||||
const folderPath = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-workspace-'))
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-folder' })
|
||||
const folderWorkspace = makeFolderWorkspace({ folderPath })
|
||||
const projectGroup = makeFolderProjectGroup({ parentPath: folderPath })
|
||||
const runtime = new OrcaRuntimeService(
|
||||
createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never
|
||||
)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.createTerminal(selector, {
|
||||
command: 'codex',
|
||||
title: 'multi-repo worker'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
worktreeId: TEST_FOLDER_WORKSPACE_KEY,
|
||||
title: 'multi-repo worker',
|
||||
surface: 'background'
|
||||
})
|
||||
|
||||
const spawnCall = spawn.mock.calls[0]?.[0] as
|
||||
| { cwd?: string; env?: Record<string, string>; worktreeId?: string }
|
||||
| undefined
|
||||
const spawnedEnv = spawnCall?.env ?? {}
|
||||
expect(spawnCall).toMatchObject({
|
||||
cwd: folderPath,
|
||||
worktreeId: TEST_FOLDER_WORKSPACE_KEY
|
||||
})
|
||||
expectStablePaneKeyEnv(spawnedEnv)
|
||||
expect(spawnedEnv.ORCA_WORKSPACE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY)
|
||||
expect(spawnedEnv.ORCA_PROJECT_GROUP_ID).toBe(TEST_FOLDER_PROJECT_GROUP_ID)
|
||||
expect(spawnedEnv.ORCA_WORKSPACE_ROOT).toBe(folderPath)
|
||||
expect(spawnedEnv.ORCA_WORKTREE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY)
|
||||
})
|
||||
|
||||
it('rejects folder workspace terminal creation when the backing path is missing', async () => {
|
||||
const missingPath = join(tmpdir(), `orca-missing-folder-workspace-${randomUUID()}`)
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-folder' })
|
||||
const folderWorkspace = makeFolderWorkspace({ folderPath: missingPath })
|
||||
const projectGroup = makeFolderProjectGroup({ parentPath: missingPath })
|
||||
const runtime = new OrcaRuntimeService(
|
||||
createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never
|
||||
)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await expect(runtime.createTerminal(TEST_FOLDER_WORKSPACE_KEY)).rejects.toThrow(
|
||||
'folder_workspace_path_missing'
|
||||
)
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects folder workspace folderPath updates when the new path is missing', async () => {
|
||||
const missingPath = join(tmpdir(), `orca-missing-folder-update-${randomUUID()}`)
|
||||
const folderWorkspace = makeFolderWorkspace()
|
||||
const runtimeStore = {
|
||||
...createFolderWorkspaceRuntimeStore(folderWorkspace),
|
||||
updateFolderWorkspace: vi.fn()
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
await expect(
|
||||
runtime.updateFolderWorkspace(TEST_FOLDER_WORKSPACE_ID, { folderPath: missingPath })
|
||||
).rejects.toThrow('folder_workspace_path_missing')
|
||||
expect(runtimeStore.updateFolderWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enables Claude Agent Teams only for direct Claude launches when configured in-process', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
const runtimeStore = {
|
||||
|
|
@ -4166,6 +4299,84 @@ describe('OrcaRuntimeService', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('splits folder workspace pty-backed terminal sessions with folder cwd and env', async () => {
|
||||
const folderPath = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-split-'))
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-folder-source' })
|
||||
.mockResolvedValueOnce({ id: 'pty-folder-split' })
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-folder' })
|
||||
const folderWorkspace = makeFolderWorkspace({ folderPath })
|
||||
const projectGroup = makeFolderProjectGroup({ parentPath: folderPath })
|
||||
const runtime = new OrcaRuntimeService(
|
||||
createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never
|
||||
)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
|
||||
const { handle } = await runtime.createTerminal(TEST_FOLDER_WORKSPACE_KEY)
|
||||
const sourceCall = spawn.mock.calls[0]?.[0] as
|
||||
| { cwd?: string; env?: Record<string, string>; worktreeId?: string }
|
||||
| undefined
|
||||
const sourceEnv = sourceCall?.env ?? {}
|
||||
const sourceLeafId = sourceEnv.ORCA_PANE_KEY.slice(`${sourceEnv.ORCA_TAB_ID}:`.length)
|
||||
|
||||
await expect(runtime.splitTerminal(handle, { direction: 'vertical' })).resolves.toMatchObject({
|
||||
handle: expect.stringMatching(/^term_/),
|
||||
tabId: sourceEnv.ORCA_TAB_ID,
|
||||
paneRuntimeId: -1
|
||||
})
|
||||
|
||||
const splitCall = spawn.mock.calls[1]?.[0] as
|
||||
| { cwd?: string; env?: Record<string, string>; worktreeId?: string }
|
||||
| undefined
|
||||
const splitEnv = splitCall?.env ?? {}
|
||||
const splitLeafId = splitEnv.ORCA_PANE_KEY.slice(`${sourceEnv.ORCA_TAB_ID}:`.length)
|
||||
expect(sourceCall).toMatchObject({
|
||||
cwd: folderPath,
|
||||
worktreeId: TEST_FOLDER_WORKSPACE_KEY
|
||||
})
|
||||
expect(splitCall).toMatchObject({
|
||||
cwd: folderPath,
|
||||
worktreeId: TEST_FOLDER_WORKSPACE_KEY
|
||||
})
|
||||
expectStablePaneKeyEnv(splitEnv)
|
||||
expect(splitEnv.ORCA_TAB_ID).toBe(sourceEnv.ORCA_TAB_ID)
|
||||
expect(splitEnv.ORCA_WORKSPACE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY)
|
||||
expect(splitEnv.ORCA_PROJECT_GROUP_ID).toBe(TEST_FOLDER_PROJECT_GROUP_ID)
|
||||
expect(splitEnv.ORCA_WORKSPACE_ROOT).toBe(folderPath)
|
||||
expect(splitEnv.ORCA_WORKTREE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY)
|
||||
expect(revealTerminalSession).toHaveBeenLastCalledWith(TEST_FOLDER_WORKSPACE_KEY, {
|
||||
ptyId: 'pty-folder-split',
|
||||
title: null,
|
||||
activate: true,
|
||||
tabId: sourceEnv.ORCA_TAB_ID,
|
||||
leafId: splitLeafId,
|
||||
splitFromLeafId: sourceLeafId,
|
||||
splitDirection: 'vertical'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a background handle when inactive tab adoption fails after spawn', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
const revealTerminalSession = vi.fn().mockRejectedValue(new Error('Renderer timed out'))
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ import type {
|
|||
LinearWorkspaceSelection,
|
||||
NestedRepoScanResult,
|
||||
ProjectGroup,
|
||||
FolderWorkspace,
|
||||
ProjectGroupImportMode,
|
||||
ProjectGroupImportResult,
|
||||
MemorySnapshot,
|
||||
|
|
@ -120,6 +121,11 @@ import {
|
|||
isPathInsideOrEqual,
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../../shared/cross-platform-path'
|
||||
import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import type {
|
||||
FolderWorkspacePathStatus,
|
||||
FolderWorkspacePathStatusRequest
|
||||
} from '../../shared/folder-workspace-path-status'
|
||||
import {
|
||||
buildKnownOrcaWorkspaceLayouts,
|
||||
isLegacyRepoForExternalWorktreeVisibility,
|
||||
|
|
@ -487,6 +493,12 @@ import { killAllProcessesForWorktree } from './worktree-teardown'
|
|||
import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits'
|
||||
import type { IFilesystemProvider, IPtyProvider } from '../providers/types'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
assertFolderWorkspacePathUsable,
|
||||
getFolderWorkspacePathStatus,
|
||||
getFolderWorkspacePathStatusForPath,
|
||||
inferFolderWorkspacePathConnection
|
||||
} from '../project-groups/folder-workspace-path-status'
|
||||
import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
||||
import { githubAvatarIcon } from '../../shared/repo-icon'
|
||||
|
|
@ -540,6 +552,10 @@ type RuntimeStore = {
|
|||
updateProjectGroup?: Store['updateProjectGroup']
|
||||
deleteProjectGroup?: Store['deleteProjectGroup']
|
||||
moveProjectToGroup?: Store['moveProjectToGroup']
|
||||
getFolderWorkspaces?: Store['getFolderWorkspaces']
|
||||
createFolderWorkspace?: Store['createFolderWorkspace']
|
||||
updateFolderWorkspace?: Store['updateFolderWorkspace']
|
||||
removeFolderWorkspace?: Store['removeFolderWorkspace']
|
||||
removeProject?: Store['removeProject']
|
||||
reorderRepos?: Store['reorderRepos']
|
||||
getAllWorktreeMeta: Store['getAllWorktreeMeta']
|
||||
|
|
@ -1194,6 +1210,13 @@ type ResolvedWorktree = Worktree & {
|
|||
git: GitWorktreeInfo
|
||||
}
|
||||
|
||||
type TerminalWorkspaceLaunchScope = {
|
||||
id: string
|
||||
path: string
|
||||
connectionId: string | null
|
||||
folderWorkspace: FolderWorkspace | null
|
||||
}
|
||||
|
||||
type WorktreeLineageInput = {
|
||||
parentWorktree?: string
|
||||
cwdParentWorktree?: string
|
||||
|
|
@ -6487,9 +6510,14 @@ export class OrcaRuntimeService {
|
|||
return this.store?.getProjectGroups?.() ?? []
|
||||
}
|
||||
|
||||
listFolderWorkspaces(): FolderWorkspace[] {
|
||||
return this.store?.getFolderWorkspaces?.() ?? []
|
||||
}
|
||||
|
||||
async createProjectGroup(input: {
|
||||
name: string
|
||||
parentPath?: string | null
|
||||
connectionId?: string | null
|
||||
parentGroupId?: string | null
|
||||
createdFrom?: ProjectGroup['createdFrom']
|
||||
}): Promise<ProjectGroup> {
|
||||
|
|
@ -6499,6 +6527,7 @@ export class OrcaRuntimeService {
|
|||
const group = this.store.createProjectGroup({
|
||||
name: input.name,
|
||||
parentPath: input.parentPath ?? null,
|
||||
connectionId: input.connectionId ?? null,
|
||||
parentGroupId: input.parentGroupId ?? null,
|
||||
createdFrom: input.createdFrom ?? 'manual'
|
||||
})
|
||||
|
|
@ -6548,6 +6577,118 @@ export class OrcaRuntimeService {
|
|||
return moved
|
||||
}
|
||||
|
||||
async createFolderWorkspace(input: {
|
||||
projectGroupId: string
|
||||
name?: string
|
||||
folderPath?: string | null
|
||||
connectionId?: string | null
|
||||
linkedTask?: FolderWorkspace['linkedTask']
|
||||
createdWithAgent?: FolderWorkspace['createdWithAgent']
|
||||
pendingFirstAgentMessageRename?: boolean
|
||||
}): Promise<FolderWorkspace> {
|
||||
if (!this.store?.createFolderWorkspace) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const projectGroups = this.store.getProjectGroups?.() ?? []
|
||||
const group = projectGroups.find((entry) => entry.id === input.projectGroupId)
|
||||
const folderPath =
|
||||
typeof input.folderPath === 'string' && input.folderPath.trim().length > 0
|
||||
? input.folderPath
|
||||
: group?.parentPath
|
||||
if (!group || !folderPath) {
|
||||
throw new Error('folder_workspace_project_group_not_found')
|
||||
}
|
||||
const status = await getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath,
|
||||
projectGroupId: group.id,
|
||||
connectionId: input.connectionId ?? group.connectionId ?? null,
|
||||
projectGroups,
|
||||
repos: this.store.getRepos()
|
||||
},
|
||||
{ getSshFilesystemProvider }
|
||||
)
|
||||
assertFolderWorkspacePathUsable(status)
|
||||
const workspace = this.store.createFolderWorkspace(input)
|
||||
this.notifyReposChanged()
|
||||
return workspace
|
||||
}
|
||||
|
||||
async getFolderWorkspacePathStatus(
|
||||
request: FolderWorkspacePathStatusRequest
|
||||
): Promise<FolderWorkspacePathStatus> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return getFolderWorkspacePathStatus(this.store, request, { getSshFilesystemProvider })
|
||||
}
|
||||
|
||||
async updateFolderWorkspace(
|
||||
folderWorkspaceId: string,
|
||||
updates: Partial<
|
||||
Pick<
|
||||
FolderWorkspace,
|
||||
| 'name'
|
||||
| 'folderPath'
|
||||
| 'linkedTask'
|
||||
| 'comment'
|
||||
| 'isArchived'
|
||||
| 'isUnread'
|
||||
| 'isPinned'
|
||||
| 'sortOrder'
|
||||
| 'manualOrder'
|
||||
| 'workspaceStatus'
|
||||
| 'createdWithAgent'
|
||||
| 'pendingFirstAgentMessageRename'
|
||||
| 'firstAgentMessageRenameError'
|
||||
| 'lastActivityAt'
|
||||
>
|
||||
>
|
||||
): Promise<FolderWorkspace | null> {
|
||||
if (!this.store?.updateFolderWorkspace) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) {
|
||||
const workspace = this.store
|
||||
.getFolderWorkspaces?.()
|
||||
.find((entry) => entry.id === folderWorkspaceId)
|
||||
if (!workspace) {
|
||||
return null
|
||||
}
|
||||
const projectGroups = this.store.getProjectGroups?.() ?? []
|
||||
const status = await getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath: updates.folderPath,
|
||||
projectGroupId: workspace.projectGroupId,
|
||||
connectionId:
|
||||
workspace.connectionId ??
|
||||
projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ??
|
||||
null,
|
||||
projectGroups,
|
||||
repos: this.store.getRepos()
|
||||
},
|
||||
{ getSshFilesystemProvider }
|
||||
)
|
||||
assertFolderWorkspacePathUsable(status)
|
||||
}
|
||||
const updated = this.store.updateFolderWorkspace(folderWorkspaceId, updates)
|
||||
if (updated) {
|
||||
this.notifyReposChanged()
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
async deleteFolderWorkspace(folderWorkspaceId: string): Promise<{ deleted: boolean }> {
|
||||
if (!this.store?.removeFolderWorkspace) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const deleted = this.store.removeFolderWorkspace(folderWorkspaceId)
|
||||
if (deleted) {
|
||||
this.notifyReposChanged()
|
||||
}
|
||||
return { deleted }
|
||||
}
|
||||
|
||||
async scanNestedRepos(path: string): Promise<NestedRepoScanResult> {
|
||||
if (!isAbsolute(path)) {
|
||||
throw new Error('Project path must be an absolute path')
|
||||
|
|
@ -6605,6 +6746,8 @@ export class OrcaRuntimeService {
|
|||
parentPath: args.parentPath,
|
||||
groupName: args.groupName,
|
||||
mode: args.mode,
|
||||
connectionId: null,
|
||||
repoPaths: selection.selectedPaths,
|
||||
createGroup: (input) => this.store!.createProjectGroup!(input)
|
||||
})
|
||||
const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map(
|
||||
|
|
@ -11147,8 +11290,7 @@ export class OrcaRuntimeService {
|
|||
if (!this.ptyController?.spawn) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
const repo = this.store?.getRepo(worktree.repoId)
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
|
||||
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
|
||||
// Why: mint tabId in main before spawn so paneKey is known at PTY env
|
||||
// build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys
|
||||
|
|
@ -11184,23 +11326,23 @@ export class OrcaRuntimeService {
|
|||
shimBin
|
||||
}).env
|
||||
})
|
||||
const env = {
|
||||
...baseEnv,
|
||||
...agentTeamsPlan?.env,
|
||||
ORCA_PANE_KEY: paneKey,
|
||||
ORCA_TAB_ID: tabId,
|
||||
ORCA_WORKTREE_ID: worktree.id
|
||||
}
|
||||
const env = this.buildTerminalWorkspaceEnv(
|
||||
workspace,
|
||||
baseEnv,
|
||||
paneKey,
|
||||
tabId,
|
||||
agentTeamsPlan?.env
|
||||
)
|
||||
const result = await this.ptyController.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: worktree.path,
|
||||
cwd: workspace.path,
|
||||
command: agentTeamsPlan?.command ?? opts.command,
|
||||
env,
|
||||
envToDelete: agentTeamsPlan?.envToDelete,
|
||||
telemetry: opts.telemetry,
|
||||
connectionId: repo?.connectionId ?? null,
|
||||
worktreeId: worktree.id,
|
||||
connectionId: workspace.connectionId,
|
||||
worktreeId: workspace.id,
|
||||
preAllocatedHandle,
|
||||
tabId,
|
||||
leafId,
|
||||
|
|
@ -11208,7 +11350,7 @@ export class OrcaRuntimeService {
|
|||
...(opts.persistHostSessionBinding ? { persistHostSessionBinding: true } : {})
|
||||
})
|
||||
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||||
this.registerPty(result.id, worktree.id, repo?.connectionId ?? null)
|
||||
this.registerPty(result.id, workspace.id, workspace.connectionId)
|
||||
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
|
||||
if (pty) {
|
||||
if (opts.title) {
|
||||
|
|
@ -11225,7 +11367,7 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
|
||||
if (pty) {
|
||||
this.publishPtyBackedMobileSessionTerminal(worktree.id, pty, {
|
||||
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
|
||||
tabId,
|
||||
leafId,
|
||||
title: opts.title ?? null,
|
||||
|
|
@ -11239,7 +11381,7 @@ export class OrcaRuntimeService {
|
|||
// failing here must not strand a live process without returning a handle.
|
||||
// Pass the pre-minted tabId so the renderer adopts under the same id
|
||||
// already baked into the PTY env — keeps paneKey hook attribution intact.
|
||||
await this.notifier.revealTerminalSession(worktree.id, {
|
||||
await this.notifier.revealTerminalSession(workspace.id, {
|
||||
ptyId: result.id,
|
||||
title: opts.title ?? null,
|
||||
activate: opts.activate === true,
|
||||
|
|
@ -11251,7 +11393,7 @@ export class OrcaRuntimeService {
|
|||
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
|
||||
}
|
||||
}
|
||||
return { handle, worktreeId: worktree.id, title: opts.title ?? null, surface }
|
||||
return { handle, worktreeId: workspace.id, title: opts.title ?? null, surface }
|
||||
}
|
||||
|
||||
this.assertGraphReady()
|
||||
|
|
@ -11259,7 +11401,7 @@ export class OrcaRuntimeService {
|
|||
// Why: mirrors browserTabCreate — when no worktree is specified, pass
|
||||
// undefined so the renderer uses its current active worktree.
|
||||
const worktreeId = worktreeSelector
|
||||
? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||||
? (await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)).id
|
||||
: undefined
|
||||
const requestId = randomUUID()
|
||||
|
||||
|
|
@ -11814,29 +11956,23 @@ export class OrcaRuntimeService {
|
|||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
const direction = opts.direction ?? 'horizontal'
|
||||
const worktree = await this.resolveWorktreeSelector(`id:${pty.worktreeId}`)
|
||||
const repo = this.store?.getRepo(worktree.repoId)
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${pty.worktreeId}`)
|
||||
const leafId = randomUUID()
|
||||
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
|
||||
const paneKey = makePaneKey(parentTabId, leafId)
|
||||
const result = await this.ptyController.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: worktree.path,
|
||||
cwd: workspace.path,
|
||||
command: opts.command,
|
||||
env: {
|
||||
...opts.env,
|
||||
ORCA_PANE_KEY: paneKey,
|
||||
ORCA_TAB_ID: parentTabId,
|
||||
ORCA_WORKTREE_ID: worktree.id
|
||||
},
|
||||
env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId),
|
||||
envToDelete: opts.envToDelete,
|
||||
connectionId: repo?.connectionId ?? null,
|
||||
worktreeId: worktree.id,
|
||||
connectionId: workspace.connectionId,
|
||||
worktreeId: workspace.id,
|
||||
preAllocatedHandle
|
||||
})
|
||||
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||||
this.registerPty(result.id, worktree.id, repo?.connectionId ?? null)
|
||||
this.registerPty(result.id, workspace.id, workspace.connectionId)
|
||||
const createdPty = this.getOrCreatePtyWorktreeRecord(result.id)
|
||||
if (createdPty) {
|
||||
createdPty.tabId = parentTabId
|
||||
|
|
@ -11844,7 +11980,7 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
|
||||
try {
|
||||
await this.notifier?.revealTerminalSession?.(worktree.id, {
|
||||
await this.notifier?.revealTerminalSession?.(workspace.id, {
|
||||
ptyId: result.id,
|
||||
title: null,
|
||||
activate: opts.activate !== false,
|
||||
|
|
@ -11859,7 +11995,7 @@ export class OrcaRuntimeService {
|
|||
throw error
|
||||
}
|
||||
if (createdPty) {
|
||||
this.publishPtyBackedMobileSessionTerminal(worktree.id, createdPty, {
|
||||
this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, {
|
||||
tabId: parentTabId,
|
||||
leafId,
|
||||
title: null,
|
||||
|
|
@ -12063,6 +12199,101 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
}
|
||||
|
||||
private resolveFolderWorkspaceConnectionId(workspace: FolderWorkspace): string | null {
|
||||
const repos = this.store?.getRepos() ?? []
|
||||
const projectGroups = this.store?.getProjectGroups?.() ?? []
|
||||
const connection = inferFolderWorkspacePathConnection({
|
||||
folderPath: workspace.folderPath,
|
||||
projectGroupId: workspace.projectGroupId,
|
||||
connectionId: workspace.connectionId ?? null,
|
||||
projectGroups,
|
||||
repos
|
||||
})
|
||||
if (connection.kind === 'ambiguous') {
|
||||
// Why: a single PTY can only be spawned on one runtime target; mixed
|
||||
// child repo connections need an explicit V2 routing decision.
|
||||
throw new Error('folder_workspace_connection_ambiguous')
|
||||
}
|
||||
return connection.kind === 'ssh' ? connection.connectionId : null
|
||||
}
|
||||
|
||||
private async resolveFolderWorkspaceLaunchScope(
|
||||
selector: string
|
||||
): Promise<TerminalWorkspaceLaunchScope | null> {
|
||||
const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector
|
||||
const parsed = parseWorkspaceKey(workspaceSelector)
|
||||
if (parsed?.type !== 'folder') {
|
||||
return null
|
||||
}
|
||||
const workspace = this.store
|
||||
?.getFolderWorkspaces?.()
|
||||
.find((entry) => entry.id === parsed.folderWorkspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error('selector_not_found')
|
||||
}
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const status = await getFolderWorkspacePathStatus(
|
||||
this.store,
|
||||
{ scope: 'folder-workspace', folderWorkspaceId: workspace.id },
|
||||
{ getSshFilesystemProvider }
|
||||
)
|
||||
assertFolderWorkspacePathUsable(status)
|
||||
return {
|
||||
id: folderWorkspaceKey(workspace.id),
|
||||
path: workspace.folderPath,
|
||||
connectionId: this.resolveFolderWorkspaceConnectionId(workspace),
|
||||
folderWorkspace: workspace
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveTerminalWorkspaceLaunchScope(
|
||||
selector: string
|
||||
): Promise<TerminalWorkspaceLaunchScope> {
|
||||
const folderScope = await this.resolveFolderWorkspaceLaunchScope(selector)
|
||||
if (folderScope) {
|
||||
return folderScope
|
||||
}
|
||||
|
||||
const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector
|
||||
const parsed = parseWorkspaceKey(workspaceSelector)
|
||||
const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
const repo = this.store?.getRepo(worktree.repoId) ?? null
|
||||
return {
|
||||
id: worktree.id,
|
||||
path: worktree.path,
|
||||
connectionId: repo?.connectionId ?? null,
|
||||
folderWorkspace: null
|
||||
}
|
||||
}
|
||||
|
||||
private buildTerminalWorkspaceEnv(
|
||||
scope: TerminalWorkspaceLaunchScope,
|
||||
baseEnv: Record<string, string>,
|
||||
paneKey: string,
|
||||
tabId: string,
|
||||
agentTeamsEnv?: Record<string, string>
|
||||
): Record<string, string> {
|
||||
const env = {
|
||||
...baseEnv,
|
||||
...agentTeamsEnv,
|
||||
ORCA_PANE_KEY: paneKey,
|
||||
ORCA_TAB_ID: tabId,
|
||||
ORCA_WORKTREE_ID: scope.id
|
||||
}
|
||||
if (!scope.folderWorkspace) {
|
||||
return env
|
||||
}
|
||||
return {
|
||||
...env,
|
||||
ORCA_WORKSPACE_ID: scope.id,
|
||||
ORCA_PROJECT_GROUP_ID: scope.folderWorkspace.projectGroupId,
|
||||
ORCA_WORKSPACE_ROOT: scope.folderWorkspace.folderPath
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveWorktreeSelector(selector: string): Promise<ResolvedWorktree> {
|
||||
const worktrees = await this.listResolvedWorktrees()
|
||||
let candidates: ResolvedWorktree[]
|
||||
|
|
@ -12687,6 +12918,11 @@ export class OrcaRuntimeService {
|
|||
this.notifyWorktreesChanged(repoId)
|
||||
}
|
||||
|
||||
notifyFolderWorkspaceChanged(): void {
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.notifyReposChanged()
|
||||
}
|
||||
|
||||
private recordPtyWorktree(
|
||||
ptyId: string,
|
||||
worktreeId: string,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
|
||||
const FolderWorkspaceLinkedTask = z
|
||||
.object({
|
||||
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
|
||||
type: z.enum(['issue', 'pr', 'mr']),
|
||||
number: z.number().finite(),
|
||||
title: requiredString('Missing linked task title'),
|
||||
url: requiredString('Missing linked task URL'),
|
||||
linearIdentifier: OptionalString,
|
||||
jiraIdentifier: OptionalString,
|
||||
repoId: OptionalString
|
||||
})
|
||||
.nullable()
|
||||
|
||||
const FolderWorkspaceCreate = z.object({
|
||||
projectGroupId: requiredString('Missing project group id'),
|
||||
name: OptionalString,
|
||||
folderPath: OptionalString.nullable().optional(),
|
||||
connectionId: OptionalString.nullable().optional(),
|
||||
linkedTask: FolderWorkspaceLinkedTask.optional(),
|
||||
createdWithAgent: z.string().refine(isTuiAgent).optional(),
|
||||
pendingFirstAgentMessageRename: z.boolean().optional()
|
||||
})
|
||||
|
||||
const FolderWorkspaceUpdate = z.object({
|
||||
folderWorkspaceId: requiredString('Missing folder workspace id'),
|
||||
updates: z.object({
|
||||
name: OptionalString,
|
||||
folderPath: OptionalString,
|
||||
linkedTask: FolderWorkspaceLinkedTask.optional(),
|
||||
comment: z.string().optional(),
|
||||
isArchived: z.boolean().optional(),
|
||||
isUnread: z.boolean().optional(),
|
||||
isPinned: z.boolean().optional(),
|
||||
sortOrder: OptionalFiniteNumber,
|
||||
manualOrder: OptionalFiniteNumber,
|
||||
workspaceStatus: OptionalString,
|
||||
createdWithAgent: z.string().refine(isTuiAgent).optional(),
|
||||
pendingFirstAgentMessageRename: z.boolean().optional(),
|
||||
firstAgentMessageRenameError: z.string().nullable().optional(),
|
||||
lastActivityAt: OptionalFiniteNumber
|
||||
})
|
||||
})
|
||||
|
||||
const FolderWorkspaceSelector = z.object({
|
||||
folderWorkspaceId: requiredString('Missing folder workspace id')
|
||||
})
|
||||
|
||||
const FolderWorkspacePathStatus = z.discriminatedUnion('scope', [
|
||||
z.object({
|
||||
scope: z.literal('folder-workspace'),
|
||||
folderWorkspaceId: requiredString('Missing folder workspace id')
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal('project-group'),
|
||||
projectGroupId: requiredString('Missing project group id')
|
||||
})
|
||||
])
|
||||
|
||||
export const FOLDER_WORKSPACE_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'folderWorkspace.list',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({
|
||||
folderWorkspaces: runtime.listFolderWorkspaces()
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'folderWorkspace.create',
|
||||
params: FolderWorkspaceCreate,
|
||||
handler: async (params, { runtime }) => ({
|
||||
folderWorkspace: await runtime.createFolderWorkspace(params)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'folderWorkspace.update',
|
||||
params: FolderWorkspaceUpdate,
|
||||
handler: async (params, { runtime }) => ({
|
||||
folderWorkspace: await runtime.updateFolderWorkspace(params.folderWorkspaceId, params.updates)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'folderWorkspace.delete',
|
||||
params: FolderWorkspaceSelector,
|
||||
handler: async (params, { runtime }) => runtime.deleteFolderWorkspace(params.folderWorkspaceId)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'folderWorkspace.getPathStatus',
|
||||
params: FolderWorkspacePathStatus,
|
||||
handler: async (params, { runtime }) => ({
|
||||
status: await runtime.getFolderWorkspacePathStatus(params)
|
||||
})
|
||||
})
|
||||
]
|
||||
|
|
@ -287,7 +287,29 @@ describe('repo RPC methods', () => {
|
|||
createProjectGroup: vi.fn().mockResolvedValue(group),
|
||||
updateProjectGroup: vi.fn().mockResolvedValue({ ...group, name: 'Core' }),
|
||||
deleteProjectGroup: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
moveProjectToGroup: vi.fn().mockResolvedValue({ id: 'repo-1', projectGroupId: group.id })
|
||||
moveProjectToGroup: vi.fn().mockResolvedValue({ id: 'repo-1', projectGroupId: group.id }),
|
||||
listFolderWorkspaces: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: group.id,
|
||||
name: 'Refund fix',
|
||||
folderPath: '/srv/platform',
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]),
|
||||
createFolderWorkspace: vi.fn().mockResolvedValue({ id: 'folder-workspace-2' }),
|
||||
updateFolderWorkspace: vi.fn().mockResolvedValue({ id: 'folder-workspace-1', comment: 'x' }),
|
||||
deleteFolderWorkspace: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
getFolderWorkspacePathStatus: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ path: '/srv/platform', exists: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
|
|
@ -313,6 +335,28 @@ describe('repo RPC methods', () => {
|
|||
order: 2
|
||||
})
|
||||
)
|
||||
const folderListResponse = await dispatcher.dispatch(makeRequest('folderWorkspace.list'))
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('folderWorkspace.create', {
|
||||
projectGroupId: group.id,
|
||||
name: 'Refund fix'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('folderWorkspace.update', {
|
||||
folderWorkspaceId: 'folder-workspace-1',
|
||||
updates: { comment: 'x' }
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('folderWorkspace.delete', { folderWorkspaceId: 'folder-workspace-1' })
|
||||
)
|
||||
const statusResponse = await dispatcher.dispatch(
|
||||
makeRequest('folderWorkspace.getPathStatus', {
|
||||
scope: 'folder-workspace',
|
||||
folderWorkspaceId: 'folder-workspace-1'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.listProjectGroups).toHaveBeenCalled()
|
||||
expect(runtime.createProjectGroup).toHaveBeenCalledWith({
|
||||
|
|
@ -326,10 +370,33 @@ describe('repo RPC methods', () => {
|
|||
})
|
||||
expect(runtime.deleteProjectGroup).toHaveBeenCalledWith(group.id)
|
||||
expect(runtime.moveProjectToGroup).toHaveBeenCalledWith('repo-1', group.id, 2)
|
||||
expect(runtime.listFolderWorkspaces).toHaveBeenCalled()
|
||||
expect(runtime.createFolderWorkspace).toHaveBeenCalledWith({
|
||||
projectGroupId: group.id,
|
||||
name: 'Refund fix'
|
||||
})
|
||||
expect(runtime.updateFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', {
|
||||
comment: 'x'
|
||||
})
|
||||
expect(runtime.deleteFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1')
|
||||
expect(runtime.getFolderWorkspacePathStatus).toHaveBeenCalledWith({
|
||||
scope: 'folder-workspace',
|
||||
folderWorkspaceId: 'folder-workspace-1'
|
||||
})
|
||||
expect(moveResponse).toMatchObject({
|
||||
ok: true,
|
||||
result: { repo: { id: 'repo-1', projectGroupId: group.id } }
|
||||
})
|
||||
expect(folderListResponse).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
folderWorkspaces: [expect.objectContaining({ id: 'folder-workspace-1' })]
|
||||
}
|
||||
})
|
||||
expect(statusResponse).toMatchObject({
|
||||
ok: true,
|
||||
result: { status: { path: '/srv/platform', exists: true } }
|
||||
})
|
||||
})
|
||||
|
||||
it('allows separate nested-repo imports without a group name', async () => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas
|
|||
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
|
||||
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
|
||||
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
|
||||
import { FOLDER_WORKSPACE_METHODS } from './folder-workspace'
|
||||
|
||||
const RepoSelector = z.object({
|
||||
repo: requiredString('Missing repo selector')
|
||||
|
|
@ -95,6 +96,7 @@ const RepoReorder = z.object({
|
|||
const ProjectGroupCreate = z.object({
|
||||
name: requiredString('Missing group name'),
|
||||
parentPath: OptionalString,
|
||||
connectionId: OptionalString.nullable().optional(),
|
||||
parentGroupId: OptionalString.nullable().optional(),
|
||||
createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional()
|
||||
})
|
||||
|
|
@ -187,6 +189,7 @@ export const REPO_METHODS: RpcMethod[] = [
|
|||
repo: await runtime.moveProjectToGroup(params.repo, params.groupId ?? null, params.order)
|
||||
})
|
||||
}),
|
||||
...FOLDER_WORKSPACE_METHODS,
|
||||
defineMethod({
|
||||
name: 'projectGroup.scanNested',
|
||||
params: ProjectGroupScanNested,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ import type {
|
|||
import type { NativeFileDropPayload } from '../shared/native-file-drop'
|
||||
import type { AppIdentity } from '../shared/app-identity'
|
||||
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
|
||||
import type {
|
||||
FolderWorkspacePathStatus,
|
||||
FolderWorkspacePathStatusRequest
|
||||
} from '../shared/folder-workspace-path-status'
|
||||
import type {
|
||||
BaseRefDefaultResult,
|
||||
BaseRefSearchResult,
|
||||
|
|
@ -122,6 +126,7 @@ import type {
|
|||
PRRefreshOutcome,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
FolderWorkspace,
|
||||
ProjectGroupImportResult,
|
||||
ProjectGroupImportMode,
|
||||
ShellHydrationFailureReason,
|
||||
|
|
@ -787,6 +792,7 @@ export type PreloadApi = {
|
|||
create: (args: {
|
||||
name: string
|
||||
parentPath?: string | null
|
||||
connectionId?: string | null
|
||||
parentGroupId?: string | null
|
||||
createdFrom?: ProjectGroup['createdFrom']
|
||||
}) => Promise<ProjectGroup>
|
||||
|
|
@ -819,6 +825,42 @@ export type PreloadApi = {
|
|||
mode: ProjectGroupImportMode
|
||||
}) => Promise<ProjectGroupImportResult>
|
||||
}
|
||||
folderWorkspaces: {
|
||||
list: () => Promise<FolderWorkspace[]>
|
||||
getPathStatus: (args: FolderWorkspacePathStatusRequest) => Promise<FolderWorkspacePathStatus>
|
||||
create: (args: {
|
||||
projectGroupId: string
|
||||
name?: string
|
||||
folderPath?: string | null
|
||||
connectionId?: string | null
|
||||
linkedTask?: FolderWorkspace['linkedTask']
|
||||
createdWithAgent?: FolderWorkspace['createdWithAgent']
|
||||
pendingFirstAgentMessageRename?: boolean
|
||||
}) => Promise<FolderWorkspace>
|
||||
update: (args: {
|
||||
folderWorkspaceId: string
|
||||
updates: Partial<
|
||||
Pick<
|
||||
FolderWorkspace,
|
||||
| 'name'
|
||||
| 'folderPath'
|
||||
| 'linkedTask'
|
||||
| 'comment'
|
||||
| 'isArchived'
|
||||
| 'isUnread'
|
||||
| 'isPinned'
|
||||
| 'sortOrder'
|
||||
| 'manualOrder'
|
||||
| 'workspaceStatus'
|
||||
| 'createdWithAgent'
|
||||
| 'pendingFirstAgentMessageRename'
|
||||
| 'firstAgentMessageRenameError'
|
||||
| 'lastActivityAt'
|
||||
>
|
||||
>
|
||||
}) => Promise<FolderWorkspace | null>
|
||||
delete: (args: { folderWorkspaceId: string }) => Promise<boolean>
|
||||
}
|
||||
sparsePresets: {
|
||||
list: (args: { repoId: string }) => Promise<SparsePreset[]>
|
||||
save: (args: {
|
||||
|
|
|
|||
|
|
@ -532,6 +532,14 @@ const api = {
|
|||
importNested: (args) => ipcRenderer.invoke('projectGroups:importNested', args)
|
||||
} satisfies PreloadApi['projectGroups'],
|
||||
|
||||
folderWorkspaces: {
|
||||
list: () => ipcRenderer.invoke('folderWorkspaces:list'),
|
||||
getPathStatus: (args) => ipcRenderer.invoke('folderWorkspaces:getPathStatus', args),
|
||||
create: (args) => ipcRenderer.invoke('folderWorkspaces:create', args),
|
||||
update: (args) => ipcRenderer.invoke('folderWorkspaces:update', args),
|
||||
delete: (args) => ipcRenderer.invoke('folderWorkspaces:delete', args)
|
||||
} satisfies PreloadApi['folderWorkspaces'],
|
||||
|
||||
sparsePresets: {
|
||||
list: (args) => ipcRenderer.invoke('sparsePresets:list', args),
|
||||
|
||||
|
|
|
|||
|
|
@ -329,6 +329,7 @@ function App(): React.JSX.Element {
|
|||
toggleSidebar: s.toggleSidebar,
|
||||
fetchRepos: s.fetchRepos,
|
||||
fetchProjectGroups: s.fetchProjectGroups,
|
||||
fetchFolderWorkspaces: s.fetchFolderWorkspaces,
|
||||
fetchAllWorktrees: s.fetchAllWorktrees,
|
||||
fetchWorktreeLineage: s.fetchWorktreeLineage,
|
||||
fetchSettings: s.fetchSettings,
|
||||
|
|
@ -732,6 +733,7 @@ function App(): React.JSX.Element {
|
|||
await actions.fetchSettings()
|
||||
await actions.fetchRepos()
|
||||
await actions.fetchProjectGroups()
|
||||
await actions.fetchFolderWorkspaces()
|
||||
await actions.fetchAllWorktrees()
|
||||
await actions.fetchWorktreeLineage()
|
||||
const persistedUI = await window.api.ui.get()
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@ type NewWorkspaceComposerCardProps = {
|
|||
selectedRepoIsGit: boolean
|
||||
onRepoChange: (value: string) => void
|
||||
primaryActionLabel: string
|
||||
projectLabel?: string
|
||||
projectPlaceholder?: string
|
||||
emptyProjectMessage?: string
|
||||
showAddProjectButton?: boolean
|
||||
name: string
|
||||
onNameValueChange: (value: string) => void
|
||||
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
|
||||
|
|
@ -86,10 +90,13 @@ type NewWorkspaceComposerCardProps = {
|
|||
selectedRepoRequiresConnection: boolean
|
||||
selectedRepoConnectInProgress: boolean
|
||||
onConnectSelectedRepo: () => Promise<void>
|
||||
branchesEnabled?: boolean
|
||||
setupControlsEnabled?: boolean
|
||||
canUseSparseCheckout: boolean
|
||||
sparsePresets: SparsePreset[]
|
||||
sparseSelectedPresetId: string | null
|
||||
onSparseSelectPreset: (preset: SparsePreset | null) => void
|
||||
sparseControlsEnabled?: boolean
|
||||
}
|
||||
|
||||
const SSH_STATUS_LABELS: Record<SshConnectionStatus, string> = {
|
||||
|
|
@ -234,6 +241,10 @@ export default function NewWorkspaceComposerCard({
|
|||
selectedRepoIsGit,
|
||||
onRepoChange,
|
||||
primaryActionLabel,
|
||||
projectLabel,
|
||||
projectPlaceholder,
|
||||
emptyProjectMessage,
|
||||
showAddProjectButton = true,
|
||||
name,
|
||||
onNameValueChange,
|
||||
onSmartGitHubItemSelect,
|
||||
|
|
@ -265,10 +276,13 @@ export default function NewWorkspaceComposerCard({
|
|||
selectedRepoRequiresConnection,
|
||||
selectedRepoConnectInProgress,
|
||||
onConnectSelectedRepo,
|
||||
branchesEnabled = true,
|
||||
setupControlsEnabled = true,
|
||||
canUseSparseCheckout,
|
||||
sparsePresets,
|
||||
sparseSelectedPresetId,
|
||||
onSparseSelectPreset
|
||||
onSparseSelectPreset,
|
||||
sparseControlsEnabled = true
|
||||
}: NewWorkspaceComposerCardProps): React.JSX.Element {
|
||||
const { isFileDragOver, dragHandlers } = useComposerFileDragOver()
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
|
|
@ -402,38 +416,41 @@ export default function NewWorkspaceComposerCard({
|
|||
<div className="space-y-1" data-contextual-tour-target="workspace-creation-project">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{translate('auto.components.NewWorkspaceComposerCard.969a8bff66', 'Project')}
|
||||
{projectLabel ??
|
||||
translate('auto.components.NewWorkspaceComposerCard.969a8bff66', 'Project')}
|
||||
</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={handleAddRepo}
|
||||
className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
|
||||
aria-label={translate(
|
||||
'auto.components.NewWorkspaceComposerCard.d6b0a96f32',
|
||||
'Add project'
|
||||
)}
|
||||
>
|
||||
<FolderPlus className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
{translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{showAddProjectButton ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={handleAddRepo}
|
||||
className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
|
||||
aria-label={translate(
|
||||
'auto.components.NewWorkspaceComposerCard.d6b0a96f32',
|
||||
'Add project'
|
||||
)}
|
||||
>
|
||||
<FolderPlus className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
{translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
<RepoCombobox
|
||||
repos={eligibleRepos}
|
||||
value={repoId}
|
||||
onValueChange={onRepoChange}
|
||||
onValueSelected={focusNameInput}
|
||||
placeholder={translate(
|
||||
'auto.components.NewWorkspaceComposerCard.dccd26d4e4',
|
||||
'Choose project'
|
||||
)}
|
||||
placeholder={
|
||||
projectPlaceholder ??
|
||||
translate('auto.components.NewWorkspaceComposerCard.dccd26d4e4', 'Choose project')
|
||||
}
|
||||
// Why: programmatic .focus() from the Dialog's onOpenAutoFocus
|
||||
// handler does not reliably trigger :focus-visible in Chromium.
|
||||
// Mirror the Input component's standard ring (border-ring +
|
||||
|
|
@ -451,10 +468,11 @@ export default function NewWorkspaceComposerCard({
|
|||
</p>
|
||||
) : eligibleRepos.length === 0 ? (
|
||||
<p id={projectDescriptionId} className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace',
|
||||
'Add a project before creating a workspace.'
|
||||
)}
|
||||
{emptyProjectMessage ??
|
||||
translate(
|
||||
'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace',
|
||||
'Add a project before creating a workspace.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{selectedRepoRequiresConnection && selectedRepoConnectionId ? (
|
||||
|
|
@ -522,6 +540,7 @@ export default function NewWorkspaceComposerCard({
|
|||
disabled={selectedRepoRequiresConnection}
|
||||
disabledPlaceholder="Connect this repo first"
|
||||
textOnly={!selectedRepoIsGit}
|
||||
branchesEnabled={branchesEnabled}
|
||||
onPlainEnter={() => {
|
||||
// Why: Enter on the workspace name advances focus to the next
|
||||
// field (Agent combobox) rather than submitting, letting the user
|
||||
|
|
@ -669,7 +688,7 @@ export default function NewWorkspaceComposerCard({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{setupConfig ? (
|
||||
{setupControlsEnabled && setupConfig ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
|
|
@ -771,29 +790,31 @@ export default function NewWorkspaceComposerCard({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.d861de981b',
|
||||
'Sparse checkout'
|
||||
)}
|
||||
</label>
|
||||
<SparseCheckoutPresetSelect
|
||||
repoId={repoId}
|
||||
presets={sparsePresets}
|
||||
selectedPresetId={sparseSelectedPresetId}
|
||||
onSelectPreset={onSparseSelectPreset}
|
||||
disabled={!canUseSparseCheckout}
|
||||
/>
|
||||
{!canUseSparseCheckout ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{sparseControlsEnabled ? (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.cbb47ee0dc',
|
||||
'Only available for local Git projects.'
|
||||
'auto.components.NewWorkspaceComposerCard.d861de981b',
|
||||
'Sparse checkout'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<SparseCheckoutPresetSelect
|
||||
repoId={repoId}
|
||||
presets={sparsePresets}
|
||||
selectedPresetId={sparseSelectedPresetId}
|
||||
onSelectPreset={onSparseSelectPreset}
|
||||
disabled={!canUseSparseCheckout}
|
||||
/>
|
||||
{!canUseSparseCheckout ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.cbb47ee0dc',
|
||||
'Only available for local Git projects.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
type BackgroundMountTerminalWorktreeDetail
|
||||
} from '@/constants/terminal'
|
||||
import { useAppStore } from '../store'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { useAllWorktrees } from '../store/selectors'
|
||||
import { getConnectionId } from '../lib/connection-context'
|
||||
import { basename } from '../lib/path'
|
||||
|
|
@ -188,6 +189,17 @@ function Terminal(): React.JSX.Element | null {
|
|||
const mountedWorktreeIdsRef = useRef(new Set<string>())
|
||||
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
|
||||
const workspaceSurfaces = useMemo(
|
||||
() => [
|
||||
...allWorktrees.map((worktree) => ({ id: worktree.id, path: worktree.path })),
|
||||
...folderWorkspaces.map((workspace) => ({
|
||||
id: folderWorkspaceKey(workspace.id),
|
||||
path: workspace.folderPath
|
||||
}))
|
||||
],
|
||||
[allWorktrees, folderWorkspaces]
|
||||
)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const renderedActiveWorktreeId = activeWorktreeId
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
|
|
@ -723,14 +735,14 @@ function Terminal(): React.JSX.Element | null {
|
|||
mountedWorktreeIdsRef.current.add(renderedActiveWorktreeId)
|
||||
}
|
||||
// Prune IDs of worktrees that no longer exist (deleted/removed)
|
||||
const allWorktreeIds = new Set(allWorktrees.map((wt) => wt.id))
|
||||
const allWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id))
|
||||
for (const id of mountedWorktreeIdsRef.current) {
|
||||
if (!allWorktreeIds.has(id)) {
|
||||
mountedWorktreeIdsRef.current.delete(id)
|
||||
}
|
||||
}
|
||||
const anyMountedWorktreeHasLayout = computeAnyMountedWorktreeHasLayout(
|
||||
allWorktrees.map((wt) => wt.id),
|
||||
workspaceSurfaces.map((workspace) => workspace.id),
|
||||
mountedWorktreeIdsRef.current,
|
||||
layoutByWorktree,
|
||||
groupsByWorktree,
|
||||
|
|
@ -1645,26 +1657,26 @@ function Terminal(): React.JSX.Element | null {
|
|||
can preserve hidden trees without reflowing the active one. Keep
|
||||
a relative anchor here so those panes size to the workspace body
|
||||
rather than some outer ancestor when split groups are enabled. */}
|
||||
{allWorktrees
|
||||
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
|
||||
.map((worktree) => {
|
||||
const layout = getEffectiveLayoutForWorktree(worktree.id)
|
||||
{workspaceSurfaces
|
||||
.filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id))
|
||||
.map((workspace) => {
|
||||
const layout = getEffectiveLayoutForWorktree(workspace.id)
|
||||
if (!layout) {
|
||||
return null
|
||||
}
|
||||
// Why: use strict equality with 'terminal' instead of !== 'settings'
|
||||
// so the terminal/browser surface hides on the tasks page too.
|
||||
const isVisible =
|
||||
activeView === 'terminal' && worktree.id === renderedActiveWorktreeId
|
||||
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
return (
|
||||
<WorktreeSplitSurface
|
||||
key={`tab-groups-${worktree.id}`}
|
||||
worktreeId={worktree.id}
|
||||
worktreePath={worktree.path}
|
||||
key={`tab-groups-${workspace.id}`}
|
||||
worktreeId={workspace.id}
|
||||
worktreePath={workspace.path}
|
||||
layout={layout}
|
||||
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
|
||||
focusedGroupId={activeGroupIdByWorktree[workspace.id]}
|
||||
isVisible={isVisible}
|
||||
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
|
||||
activityTerminalPortals={activityTerminalPortals}
|
||||
|
|
@ -1709,18 +1721,18 @@ function Terminal(): React.JSX.Element | null {
|
|||
: ''
|
||||
}`}
|
||||
>
|
||||
{allWorktrees
|
||||
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
|
||||
.map((worktree) => {
|
||||
{workspaceSurfaces
|
||||
.filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id))
|
||||
.map((workspace) => {
|
||||
// Why: use strict equality with 'terminal' instead of !== 'settings'
|
||||
// so the terminal/browser surface hides on the tasks page too.
|
||||
const isVisible =
|
||||
activeView === 'terminal' && worktree.id === renderedActiveWorktreeId
|
||||
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
return (
|
||||
<div
|
||||
key={worktree.id}
|
||||
key={workspace.id}
|
||||
className={
|
||||
isVisible
|
||||
? 'absolute inset-0'
|
||||
|
|
@ -1730,11 +1742,11 @@ function Terminal(): React.JSX.Element | null {
|
|||
}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
<CodexRestartChip worktreeId={worktree.id} />
|
||||
{(tabsByWorktree[worktree.id] ?? []).map((tab) => {
|
||||
<CodexRestartChip worktreeId={workspace.id} />
|
||||
{(tabsByWorktree[workspace.id] ?? []).map((tab) => {
|
||||
const activityTerminalPortal = findActivityTerminalPortal(
|
||||
activityTerminalPortals,
|
||||
{ worktreeId: worktree.id, tabId: tab.id }
|
||||
{ worktreeId: workspace.id, tabId: tab.id }
|
||||
)
|
||||
const isActivityPortalTab = activityTerminalPortal !== null
|
||||
const isActiveTerminalTab =
|
||||
|
|
@ -1743,8 +1755,8 @@ function Terminal(): React.JSX.Element | null {
|
|||
<TerminalPane
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
tabId={tab.id}
|
||||
worktreeId={worktree.id}
|
||||
cwd={worktree.path}
|
||||
worktreeId={workspace.id}
|
||||
cwd={workspace.path}
|
||||
isActive={isActiveTerminalTab || activityTerminalPortal?.active === true}
|
||||
// Why: the activity page hosts this existing pane via
|
||||
// portal while the workspace surface remains hidden.
|
||||
|
|
@ -1781,18 +1793,18 @@ function Terminal(): React.JSX.Element | null {
|
|||
activeTabType !== 'browser' ? 'hidden' : ''
|
||||
}`}
|
||||
>
|
||||
{allWorktrees.map((worktree) => {
|
||||
const browserTabs = browserTabsByWorktree[worktree.id] ?? []
|
||||
{workspaceSurfaces.map((workspace) => {
|
||||
const browserTabs = browserTabsByWorktree[workspace.id] ?? []
|
||||
// Why: use strict equality with 'terminal' instead of !== 'settings'
|
||||
// so browser panes also hide on the tasks page.
|
||||
const isVisibleWorktree =
|
||||
activeView === 'terminal' && worktree.id === renderedActiveWorktreeId
|
||||
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
|
||||
if (browserTabs.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`browser-${worktree.id}`}
|
||||
key={`browser-${workspace.id}`}
|
||||
className={isVisibleWorktree ? 'absolute inset-0' : 'absolute inset-0 hidden'}
|
||||
aria-hidden={!isVisibleWorktree}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -30,19 +30,40 @@ export function HeroIntro({ onStart }: { onStart: () => void }): React.JSX.Eleme
|
|||
return (
|
||||
<div className="mp-intro-shell">
|
||||
<div className="mp-eyebrow-row">
|
||||
<span className="mp-eyebrow">{translate("auto.components.mobile.MobileHero.5410d55d79", "Orca Mobile")}</span>
|
||||
<span className="mp-eyebrow">
|
||||
{translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mp-h1">{translate("auto.components.mobile.MobileHero.cd4e5e816f", "Your workspaces, in your pocket.")}</h1>
|
||||
<h1 className="mp-h1">
|
||||
{translate(
|
||||
'auto.components.mobile.MobileHero.cd4e5e816f',
|
||||
'Your workspaces, in your pocket.'
|
||||
)}
|
||||
</h1>
|
||||
<p className="mp-lead">
|
||||
{translate("auto.components.mobile.MobileHero.b4ccce5cb7", "Control Orca from your phone. Check on agents, review changes, and kick off tasks while you're away from your desk.")}</p>
|
||||
<div className="mp-platform-badges" aria-label={translate("auto.components.mobile.MobileHero.ec0607bf66", "Supported mobile platforms")}>
|
||||
<span className="mp-platform-label">{translate("auto.components.mobile.MobileHero.da1d5e5ed0", "Available on")}</span>
|
||||
{translate(
|
||||
'auto.components.mobile.MobileHero.b4ccce5cb7',
|
||||
"Control Orca from your phone. Check on agents, review changes, and kick off tasks while you're away from your desk."
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
className="mp-platform-badges"
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.ec0607bf66',
|
||||
'Supported mobile platforms'
|
||||
)}
|
||||
>
|
||||
<span className="mp-platform-label">
|
||||
{translate('auto.components.mobile.MobileHero.da1d5e5ed0', 'Available on')}
|
||||
</span>
|
||||
<span className="mp-platform-badge">
|
||||
<IosBrandIcon />
|
||||
{translate("auto.components.mobile.MobileHero.711e6f4b47", "iOS")}</span>
|
||||
{translate('auto.components.mobile.MobileHero.711e6f4b47', 'iOS')}
|
||||
</span>
|
||||
<span className="mp-platform-badge">
|
||||
<AndroidLogo />
|
||||
{translate("auto.components.mobile.MobileHero.ac1eb64952", "Android")}</span>
|
||||
{translate('auto.components.mobile.MobileHero.ac1eb64952', 'Android')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mp-cta-row">
|
||||
<button
|
||||
|
|
@ -50,7 +71,8 @@ export function HeroIntro({ onStart }: { onStart: () => void }): React.JSX.Eleme
|
|||
className="mp-primary-action mp-flow-primary-action"
|
||||
onClick={onStart}
|
||||
>
|
||||
{translate("auto.components.mobile.MobileHero.10d27b4cba", "Get started")}<ArrowRight className="size-3.5" />
|
||||
{translate('auto.components.mobile.MobileHero.10d27b4cba', 'Get started')}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -73,13 +95,21 @@ export function HeroPaired({
|
|||
return (
|
||||
<div>
|
||||
<div className="mp-eyebrow-row">
|
||||
<span className="mp-eyebrow">{translate("auto.components.mobile.MobileHero.5410d55d79", "Orca Mobile")}</span>
|
||||
<span className="mp-eyebrow">
|
||||
{translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mp-h1">
|
||||
{devices.length === 1 ? translate("auto.components.mobile.MobileHero.051978a785", "Your phone is paired.") : translate("auto.components.mobile.MobileHero.d0b52871ce", "Your phones are paired.")}
|
||||
{devices.length === 1
|
||||
? translate('auto.components.mobile.MobileHero.051978a785', 'Your phone is paired.')
|
||||
: translate('auto.components.mobile.MobileHero.d0b52871ce', 'Your phones are paired.')}
|
||||
</h1>
|
||||
<p className="mp-lead-sm">
|
||||
{translate("auto.components.mobile.MobileHero.266c18c105", "Open Orca Mobile to pick up where you left off, or pair another device.")}</p>
|
||||
{translate(
|
||||
'auto.components.mobile.MobileHero.266c18c105',
|
||||
'Open Orca Mobile to pick up where you left off, or pair another device.'
|
||||
)}
|
||||
</p>
|
||||
<ul className="mp-paired-list">
|
||||
{devices.map((device) => {
|
||||
const revoking = revokingDeviceIds.includes(device.deviceId)
|
||||
|
|
@ -91,7 +121,7 @@ export function HeroPaired({
|
|||
<div className="mp-paired-main">
|
||||
<div className="mp-paired-name">{device.name}</div>
|
||||
<div className="mp-paired-meta">
|
||||
{translate("auto.components.mobile.MobileHero.94829abdb1", "Paired")}{' '}
|
||||
{translate('auto.components.mobile.MobileHero.94829abdb1', 'Paired')}{' '}
|
||||
{new Date(device.pairedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -100,8 +130,12 @@ export function HeroPaired({
|
|||
className="mp-paired-revoke"
|
||||
onClick={() => onRevoke(device.deviceId)}
|
||||
disabled={revoking}
|
||||
aria-label={translate("auto.components.mobile.MobileHero.34f878d04f", "Revoke {{value0}}", { value0: device.name })}
|
||||
title={translate("auto.components.mobile.MobileHero.f9cbf4bb53", "Revoke device")}
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.34f878d04f',
|
||||
'Revoke {{value0}}',
|
||||
{ value0: device.name }
|
||||
)}
|
||||
title={translate('auto.components.mobile.MobileHero.f9cbf4bb53', 'Revoke device')}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
|
|
@ -112,7 +146,8 @@ export function HeroPaired({
|
|||
<div className="mp-flow-actions">
|
||||
<button type="button" className="mp-secondary-action" onClick={onPairAnother}>
|
||||
<Smartphone className="size-3.5" />
|
||||
{translate("auto.components.mobile.MobileHero.ff48d9d520", "Pair another device")}</button>
|
||||
{translate('auto.components.mobile.MobileHero.ff48d9d520', 'Pair another device')}
|
||||
</button>
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -174,11 +209,19 @@ export function HeroFlow({
|
|||
<div className="mp-step2-copy">
|
||||
<div className="mp-eyebrow-row">
|
||||
<div className="mp-step-num">{stepIdx + 1}</div>
|
||||
<span className="mp-eyebrow">{translate("auto.components.mobile.MobileHero.92ddfdfa1f", "Step 1 of 2")}</span>
|
||||
<span className="mp-eyebrow">
|
||||
{translate('auto.components.mobile.MobileHero.92ddfdfa1f', 'Step 1 of 2')}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mp-h2">{translate("auto.components.mobile.MobileHero.0d9b33299e", "Get the app.")}</h2>
|
||||
<h2 className="mp-h2">
|
||||
{translate('auto.components.mobile.MobileHero.0d9b33299e', 'Get the app.')}
|
||||
</h2>
|
||||
<p className="mp-lead-sm">
|
||||
{translate("auto.components.mobile.MobileHero.e75647ace0", "Scan the QR with your phone or open the install link to grab Orca Mobile.")}</p>
|
||||
{translate(
|
||||
'auto.components.mobile.MobileHero.e75647ace0',
|
||||
'Scan the QR with your phone or open the install link to grab Orca Mobile.'
|
||||
)}
|
||||
</p>
|
||||
<div className="mp-tab-toggle">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -187,7 +230,8 @@ export function HeroFlow({
|
|||
onClick={() => onPlatformChange('ios')}
|
||||
>
|
||||
<IosBrandIcon />
|
||||
{translate("auto.components.mobile.MobileHero.711e6f4b47", "iOS")}</button>
|
||||
{translate('auto.components.mobile.MobileHero.711e6f4b47', 'iOS')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(platform === 'android' && 'is-active')}
|
||||
|
|
@ -195,7 +239,8 @@ export function HeroFlow({
|
|||
onClick={() => onPlatformChange('android')}
|
||||
>
|
||||
<AndroidLogo />
|
||||
{translate("auto.components.mobile.MobileHero.ac1eb64952", "Android")}</button>
|
||||
{translate('auto.components.mobile.MobileHero.ac1eb64952', 'Android')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mp-inline-actions">
|
||||
<button type="button" className="mp-ghost-action" onClick={onOpenInstallUrl}>
|
||||
|
|
@ -203,11 +248,23 @@ export function HeroFlow({
|
|||
</button>
|
||||
<button type="button" className="mp-text-link" onClick={onCopyInstallUrl}>
|
||||
<Copy className="size-3.5" />
|
||||
{translate("auto.components.mobile.MobileHero.aa97420ba4", "Copy install link")}</button>
|
||||
{translate('auto.components.mobile.MobileHero.aa97420ba4', 'Copy install link')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mp-qr" aria-label={translate("auto.components.mobile.MobileHero.7af266b80d", "Install QR code")}>
|
||||
{installQrUrl ? <img src={installQrUrl} alt={translate("auto.components.mobile.MobileHero.3241f3c26a", "Install QR")} /> : null}
|
||||
<div
|
||||
className="mp-qr"
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.7af266b80d',
|
||||
'Install QR code'
|
||||
)}
|
||||
>
|
||||
{installQrUrl ? (
|
||||
<img
|
||||
src={installQrUrl}
|
||||
alt={translate('auto.components.mobile.MobileHero.3241f3c26a', 'Install QR')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -217,19 +274,26 @@ export function HeroFlow({
|
|||
<div className="mp-step2-copy">
|
||||
<div className="mp-eyebrow-row">
|
||||
<div className="mp-step-num">2</div>
|
||||
<span className="mp-eyebrow">{translate("auto.components.mobile.MobileHero.3960f5c339", "Step 2 of 2")}</span>
|
||||
<span className="mp-eyebrow">
|
||||
{translate('auto.components.mobile.MobileHero.3960f5c339', 'Step 2 of 2')}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mp-h2">
|
||||
{translate("auto.components.mobile.MobileHero.901c98bb93", "Pair this")} {getDeviceLabel()}.
|
||||
{translate('auto.components.mobile.MobileHero.901c98bb93', 'Pair this')}{' '}
|
||||
{getDeviceLabel()}.
|
||||
</h2>
|
||||
<p className="mp-lead-sm">
|
||||
{translate("auto.components.mobile.MobileHero.d1495e5e64", "Open Orca Mobile, tap")}{' '}
|
||||
<strong>{translate("auto.components.mobile.MobileHero.3aa7bb2d8b", "Pair Desktop")}</strong>
|
||||
{translate("auto.components.mobile.MobileHero.2f077ef4eb", ", and scan the code.")}
|
||||
{translate('auto.components.mobile.MobileHero.d1495e5e64', 'Open Orca Mobile, tap')}{' '}
|
||||
<strong>
|
||||
{translate('auto.components.mobile.MobileHero.3aa7bb2d8b', 'Pair Desktop')}
|
||||
</strong>
|
||||
{translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')}
|
||||
</p>
|
||||
|
||||
<div className="mp-network-row">
|
||||
<span className="mp-network-label">{translate("auto.components.mobile.MobileHero.dfd2aa9d5d", "Network")}</span>
|
||||
<span className="mp-network-label">
|
||||
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
|
||||
</span>
|
||||
<Select
|
||||
value={selectedAddress ?? ''}
|
||||
onValueChange={onSelectedAddressChange}
|
||||
|
|
@ -238,9 +302,17 @@ export function HeroFlow({
|
|||
<SelectTrigger
|
||||
size="sm"
|
||||
className="mp-network-select"
|
||||
aria-label={translate("auto.components.mobile.MobileHero.79d2f480da", "Network interface to advertise")}
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.79d2f480da',
|
||||
'Network interface to advertise'
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder={translate("auto.components.mobile.MobileHero.ca85e595a7", "No interfaces found")} />
|
||||
<SelectValue
|
||||
placeholder={translate(
|
||||
'auto.components.mobile.MobileHero.ca85e595a7',
|
||||
'No interfaces found'
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{networkInterfaces.map((iface) => (
|
||||
|
|
@ -255,15 +327,23 @@ export function HeroFlow({
|
|||
className={cn('mp-network-refresh', refreshingNetworkInterfaces && 'is-spinning')}
|
||||
onClick={onRefreshNetworkInterfaces}
|
||||
disabled={refreshingNetworkInterfaces}
|
||||
aria-label={translate("auto.components.mobile.MobileHero.85067b9e06", "Refresh network interfaces")}
|
||||
title={translate("auto.components.mobile.MobileHero.85067b9e06", "Refresh network interfaces")}
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.85067b9e06',
|
||||
'Refresh network interfaces'
|
||||
)}
|
||||
title={translate(
|
||||
'auto.components.mobile.MobileHero.85067b9e06',
|
||||
'Refresh network interfaces'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mp-inline-actions">
|
||||
<span className="mp-action-divider">{translate("auto.components.mobile.MobileHero.4c1df4eba7", "Can't scan?")}</span>
|
||||
<span className="mp-action-divider">
|
||||
{translate('auto.components.mobile.MobileHero.4c1df4eba7', "Can't scan?")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mp-text-link"
|
||||
|
|
@ -271,19 +351,28 @@ export function HeroFlow({
|
|||
disabled={!pairingUrl || pairLoading}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
{translate("auto.components.mobile.MobileHero.010dddcf27", "Copy pairing code")}</button>
|
||||
{translate('auto.components.mobile.MobileHero.010dddcf27', 'Copy pairing code')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mp-qr-stack">
|
||||
<div
|
||||
className="mp-qr"
|
||||
aria-label={translate("auto.components.mobile.MobileHero.bb0074ce11", "Pairing QR code")}
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.bb0074ce11',
|
||||
'Pairing QR code'
|
||||
)}
|
||||
aria-busy={pairLoading && !pairQrDataUrl}
|
||||
>
|
||||
{pairQrDataUrl ? (
|
||||
<img src={pairQrDataUrl} alt={translate("auto.components.mobile.MobileHero.27735e5f4e", "Pairing QR")} />
|
||||
<img
|
||||
src={pairQrDataUrl}
|
||||
alt={translate('auto.components.mobile.MobileHero.27735e5f4e', 'Pairing QR')}
|
||||
/>
|
||||
) : pairLoading ? (
|
||||
<span className="mp-qr-loading">{translate("auto.components.mobile.MobileHero.65b3f2e8bc", "Generating…")}</span>
|
||||
<span className="mp-qr-loading">
|
||||
{translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -292,7 +381,11 @@ export function HeroFlow({
|
|||
onClick={onRegeneratePairing}
|
||||
disabled={pairLoading}
|
||||
>
|
||||
{pairLoading ? translate("auto.components.mobile.MobileHero.65b3f2e8bc", "Generating…") : pairQrDataUrl ? translate("auto.components.mobile.MobileHero.e59a252eca", "Regenerate code") : translate("auto.components.mobile.MobileHero.a6cffbbb0b", "Generate code")}
|
||||
{pairLoading
|
||||
? translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')
|
||||
: pairQrDataUrl
|
||||
? translate('auto.components.mobile.MobileHero.e59a252eca', 'Regenerate code')
|
||||
: translate('auto.components.mobile.MobileHero.a6cffbbb0b', 'Generate code')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -302,7 +395,8 @@ export function HeroFlow({
|
|||
<div className="mp-flow-actions">
|
||||
<button type="button" className="mp-flow-back" onClick={onBack}>
|
||||
<ArrowLeft className="size-3" />
|
||||
{translate("auto.components.mobile.MobileHero.b622eba64d", "Back")}</button>
|
||||
{translate('auto.components.mobile.MobileHero.b622eba64d', 'Back')}
|
||||
</button>
|
||||
{isLast ? (
|
||||
onDone ? (
|
||||
<button
|
||||
|
|
@ -310,7 +404,8 @@ export function HeroFlow({
|
|||
className="mp-primary-action mp-flow-primary-action"
|
||||
onClick={onDone}
|
||||
>
|
||||
{translate("auto.components.mobile.MobileHero.3f90dbd274", "Done")}<ArrowRight className="size-3.5" />
|
||||
{translate('auto.components.mobile.MobileHero.3f90dbd274', 'Done')}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
|
|
@ -321,7 +416,8 @@ export function HeroFlow({
|
|||
className="mp-flow-continue mp-flow-primary-action"
|
||||
onClick={onContinue}
|
||||
>
|
||||
{translate("auto.components.mobile.MobileHero.a8fb43cf1c", "Continue")}<ArrowRight className="size-3.5" />
|
||||
{translate('auto.components.mobile.MobileHero.a8fb43cf1c', 'Continue')}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ type SmartWorkspaceNameFieldProps = {
|
|||
disabled?: boolean
|
||||
disabledPlaceholder?: string
|
||||
textOnly?: boolean
|
||||
branchesEnabled?: boolean
|
||||
}
|
||||
|
||||
export type SmartWorkspaceNameSelection = {
|
||||
|
|
@ -178,7 +179,8 @@ export default function SmartWorkspaceNameField({
|
|||
onPlainEnter,
|
||||
disabled = false,
|
||||
disabledPlaceholder,
|
||||
textOnly = false
|
||||
textOnly = false,
|
||||
branchesEnabled = true
|
||||
}: SmartWorkspaceNameFieldProps): React.JSX.Element {
|
||||
const {
|
||||
addRepo,
|
||||
|
|
@ -267,9 +269,12 @@ export default function SmartWorkspaceNameField({
|
|||
if (item.id === 'linear') {
|
||||
return linearAvailable
|
||||
}
|
||||
if (item.id === 'branches') {
|
||||
return branchesEnabled
|
||||
}
|
||||
return true
|
||||
}),
|
||||
[gitlabAvailable, linearAvailable, textOnly]
|
||||
[branchesEnabled, gitlabAvailable, linearAvailable, textOnly]
|
||||
)
|
||||
|
||||
const selectedSourceFocusKey = selectedSource
|
||||
|
|
@ -531,13 +536,14 @@ export default function SmartWorkspaceNameField({
|
|||
() =>
|
||||
getBranchSearchRequest({
|
||||
disabled,
|
||||
branchesEnabled,
|
||||
textOnly,
|
||||
mode,
|
||||
selectedRepoId: selectedRepo?.id ?? null,
|
||||
query: debouncedQuery,
|
||||
limit: RESULT_LIMIT
|
||||
}),
|
||||
[debouncedQuery, disabled, mode, selectedRepo?.id, textOnly]
|
||||
[branchesEnabled, debouncedQuery, disabled, mode, selectedRepo?.id, textOnly]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -913,8 +919,12 @@ export default function SmartWorkspaceNameField({
|
|||
? (disabledPlaceholder ?? 'Unavailable')
|
||||
: mode === 'smart'
|
||||
? linearAvailable
|
||||
? 'Type a name, #1234, branch, GitHub or Linear URL'
|
||||
: 'Type a name, #1234, branch, or GitHub URL'
|
||||
? branchesEnabled
|
||||
? 'Type a name, #1234, branch, GitHub or Linear URL'
|
||||
: 'Type a name, #1234, GitHub or Linear URL'
|
||||
: branchesEnabled
|
||||
? 'Type a name, #1234, branch, or GitHub URL'
|
||||
: 'Type a name, #1234, or GitHub URL'
|
||||
: mode === 'github'
|
||||
? 'Search GitHub PRs and issues'
|
||||
: mode === 'branches'
|
||||
|
|
|
|||
|
|
@ -20,6 +20,31 @@ describe('Branch source results', () => {
|
|||
).toEqual({ repoId: 'repo-1', query: '', limit: 12 })
|
||||
})
|
||||
|
||||
it('does not request branch results when branches are disabled', () => {
|
||||
expect(
|
||||
getBranchSearchRequest({
|
||||
branchesEnabled: false,
|
||||
disabled: false,
|
||||
textOnly: false,
|
||||
mode: 'branches',
|
||||
selectedRepoId: 'repo-1',
|
||||
query: '',
|
||||
limit: 12
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
getBranchSearchRequest({
|
||||
branchesEnabled: false,
|
||||
disabled: false,
|
||||
textOnly: false,
|
||||
mode: 'smart',
|
||||
selectedRepoId: 'repo-1',
|
||||
query: 'refund',
|
||||
limit: 12
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps Smart mode in its start-typing state for an empty query', () => {
|
||||
expect(
|
||||
getBranchSearchRequest({
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export function getSmartWorkspaceEmptyHint(mode: SmartNameMode): string {
|
|||
}
|
||||
|
||||
export function getBranchSearchRequest({
|
||||
branchesEnabled,
|
||||
disabled,
|
||||
textOnly,
|
||||
mode,
|
||||
|
|
@ -39,6 +40,7 @@ export function getBranchSearchRequest({
|
|||
query,
|
||||
limit
|
||||
}: {
|
||||
branchesEnabled?: boolean
|
||||
disabled: boolean
|
||||
textOnly: boolean
|
||||
mode: SmartNameMode
|
||||
|
|
@ -48,7 +50,13 @@ export function getBranchSearchRequest({
|
|||
}): { repoId: string; query: string; limit: number } | null {
|
||||
const trimmedQuery = query.trim()
|
||||
const shouldSearchBranches = mode === 'branches' || (mode === 'smart' && trimmedQuery.length > 0)
|
||||
if (disabled || textOnly || !selectedRepoId || !shouldSearchBranches) {
|
||||
if (
|
||||
branchesEnabled === false ||
|
||||
disabled ||
|
||||
textOnly ||
|
||||
!selectedRepoId ||
|
||||
!shouldSearchBranches
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { repoId: selectedRepoId, query: trimmedQuery, limit }
|
||||
|
|
|
|||
|
|
@ -134,7 +134,12 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
|
|||
const resolved = preview.found ? preview : await window.api.settings.previewGhosttyImport()
|
||||
if (!resolved.found || Object.keys(resolved.diff).length === 0) {
|
||||
if (mountedRef.current) {
|
||||
toast.info(translate("auto.components.onboarding.ThemeStep.16a9f0446a", "No Ghostty settings found to import"))
|
||||
toast.info(
|
||||
translate(
|
||||
'auto.components.onboarding.ThemeStep.16a9f0446a',
|
||||
'No Ghostty settings found to import'
|
||||
)
|
||||
)
|
||||
}
|
||||
track('onboarding_ghostty_import_failed', { reason: 'empty_diff' })
|
||||
return
|
||||
|
|
@ -165,9 +170,15 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
|
|||
})
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
toast.error(translate("auto.components.onboarding.ThemeStep.699ddf83c2", "Failed to import Ghostty settings"), {
|
||||
description: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.onboarding.ThemeStep.699ddf83c2',
|
||||
'Failed to import Ghostty settings'
|
||||
),
|
||||
{
|
||||
description: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
)
|
||||
}
|
||||
track('onboarding_ghostty_import_failed', { reason: 'unknown' })
|
||||
} finally {
|
||||
|
|
@ -183,9 +194,24 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
|
|||
hint: string
|
||||
icon: typeof Monitor
|
||||
}[] = [
|
||||
{ id: 'system', label: translate("auto.components.onboarding.ThemeStep.827ea7b4a2", "System"), hint: 'Match OS', icon: Monitor },
|
||||
{ id: 'dark', label: translate("auto.components.onboarding.ThemeStep.fa7b673ea9", "Dark"), hint: 'Easy on the eyes', icon: Moon },
|
||||
{ id: 'light', label: translate("auto.components.onboarding.ThemeStep.ad192706e6", "Light"), hint: 'Bright & crisp', icon: Sun }
|
||||
{
|
||||
id: 'system',
|
||||
label: translate('auto.components.onboarding.ThemeStep.827ea7b4a2', 'System'),
|
||||
hint: 'Match OS',
|
||||
icon: Monitor
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
label: translate('auto.components.onboarding.ThemeStep.fa7b673ea9', 'Dark'),
|
||||
hint: 'Easy on the eyes',
|
||||
icon: Moon
|
||||
},
|
||||
{
|
||||
id: 'light',
|
||||
label: translate('auto.components.onboarding.ThemeStep.ad192706e6', 'Light'),
|
||||
hint: 'Bright & crisp',
|
||||
icon: Sun
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
@ -234,8 +260,13 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
|
|||
<div className="flex items-center gap-2 px-1 text-[12px] text-muted-foreground">
|
||||
<Settings2 className="size-3.5" />
|
||||
<span>
|
||||
{translate("auto.components.onboarding.ThemeStep.dd5c16ad1b", "More terminal options, including font, cursor, and palette, in")}{' '}
|
||||
<span className="font-medium text-foreground">{translate("auto.components.onboarding.ThemeStep.94b9dc561d", "Settings → Terminal")}</span>
|
||||
{translate(
|
||||
'auto.components.onboarding.ThemeStep.dd5c16ad1b',
|
||||
'More terminal options, including font, cursor, and palette, in'
|
||||
)}{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{translate('auto.components.onboarding.ThemeStep.94b9dc561d', 'Settings → Terminal')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -264,7 +295,11 @@ function GhosttyDiscoveryRow({
|
|||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-dashed border-border bg-transparent px-3.5 py-2.5 text-[12px] text-muted-foreground">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-muted-foreground/60" />
|
||||
{translate("auto.components.onboarding.ThemeStep.2c3aa538f8", "Looking for a Ghostty config…")}</div>
|
||||
{translate(
|
||||
'auto.components.onboarding.ThemeStep.2c3aa538f8',
|
||||
'Looking for a Ghostty config…'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -273,7 +308,9 @@ function GhosttyDiscoveryRow({
|
|||
<div className="flex items-center gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/[0.07] px-3.5 py-2.5 text-[12px] text-foreground">
|
||||
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" strokeWidth={3} />
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{translate("auto.components.onboarding.ThemeStep.78b6386140", "Imported from Ghostty.")}</span>
|
||||
<span className="font-medium">
|
||||
{translate('auto.components.onboarding.ThemeStep.78b6386140', 'Imported from Ghostty.')}
|
||||
</span>
|
||||
{discovery.fields.length > 0 && (
|
||||
<span className="text-muted-foreground"> {discovery.fields.join(' · ')}</span>
|
||||
)}
|
||||
|
|
@ -288,10 +325,18 @@ function GhosttyDiscoveryRow({
|
|||
<img src={ghosttyIcon} alt="" className="size-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] text-foreground">
|
||||
<span className="font-medium">{translate("auto.components.onboarding.ThemeStep.7ee9234e54", "Ghostty config detected.")}</span>{' '}
|
||||
<span className="font-medium">
|
||||
{translate(
|
||||
'auto.components.onboarding.ThemeStep.7ee9234e54',
|
||||
'Ghostty config detected.'
|
||||
)}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{translate("auto.components.onboarding.ThemeStep.248c812283", "Import")}{' '}
|
||||
{fields.length > 0 ? fields.map((f) => f.toLowerCase()).join(', ') : translate("auto.components.onboarding.ThemeStep.906c4373fe", "settings")}?
|
||||
{translate('auto.components.onboarding.ThemeStep.248c812283', 'Import')}{' '}
|
||||
{fields.length > 0
|
||||
? fields.map((f) => f.toLowerCase()).join(', ')
|
||||
: translate('auto.components.onboarding.ThemeStep.906c4373fe', 'settings')}
|
||||
?
|
||||
</span>
|
||||
</div>
|
||||
{preview.configPath && (
|
||||
|
|
@ -308,7 +353,9 @@ function GhosttyDiscoveryRow({
|
|||
disabled={importing || disabled}
|
||||
onClick={() => onImport(preview)}
|
||||
>
|
||||
{importing ? translate("auto.components.onboarding.ThemeStep.ad19e5c916", "Importing…") : translate("auto.components.onboarding.ThemeStep.248c812283", "Import")}
|
||||
{importing
|
||||
? translate('auto.components.onboarding.ThemeStep.ad19e5c916', 'Importing…')
|
||||
: translate('auto.components.onboarding.ThemeStep.248c812283', 'Import')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -392,24 +439,42 @@ function humanFields(diff: Partial<GlobalSettings>): string[] {
|
|||
// stays tidy. Anything in the diff that doesn't match a label still gets
|
||||
// imported; it just isn't surfaced as a chip.
|
||||
const groups: { label: string; keys: (keyof GlobalSettings)[] }[] = [
|
||||
{ label: translate("auto.components.onboarding.ThemeStep.cc1858e19e", "Font"), keys: ['terminalFontFamily', 'terminalFontSize', 'terminalFontWeight'] },
|
||||
{
|
||||
label: translate("auto.components.onboarding.ThemeStep.ab2a583a97", "Cursor"),
|
||||
label: translate('auto.components.onboarding.ThemeStep.cc1858e19e', 'Font'),
|
||||
keys: ['terminalFontFamily', 'terminalFontSize', 'terminalFontWeight']
|
||||
},
|
||||
{
|
||||
label: translate('auto.components.onboarding.ThemeStep.ab2a583a97', 'Cursor'),
|
||||
keys: ['terminalCursorStyle', 'terminalCursorBlink', 'terminalCursorOpacity']
|
||||
},
|
||||
{ label: translate("auto.components.onboarding.ThemeStep.c021e9dddd", "Theme palette"), keys: ['terminalThemeDark', 'terminalThemeLight'] },
|
||||
{ label: translate("auto.components.onboarding.ThemeStep.06a24f4f2d", "Colors"), keys: ['terminalColorOverrides'] },
|
||||
{ label: translate("auto.components.onboarding.ThemeStep.86c0f1caa2", "Padding"), keys: ['terminalPaddingX', 'terminalPaddingY'] },
|
||||
{
|
||||
label: translate("auto.components.onboarding.ThemeStep.b3a99a2d29", "Window"),
|
||||
label: translate('auto.components.onboarding.ThemeStep.c021e9dddd', 'Theme palette'),
|
||||
keys: ['terminalThemeDark', 'terminalThemeLight']
|
||||
},
|
||||
{
|
||||
label: translate('auto.components.onboarding.ThemeStep.06a24f4f2d', 'Colors'),
|
||||
keys: ['terminalColorOverrides']
|
||||
},
|
||||
{
|
||||
label: translate('auto.components.onboarding.ThemeStep.86c0f1caa2', 'Padding'),
|
||||
keys: ['terminalPaddingX', 'terminalPaddingY']
|
||||
},
|
||||
{
|
||||
label: translate('auto.components.onboarding.ThemeStep.b3a99a2d29', 'Window'),
|
||||
keys: ['terminalBackgroundOpacity', 'windowBackgroundBlur', 'terminalInactivePaneOpacity']
|
||||
},
|
||||
{
|
||||
label: translate("auto.components.onboarding.ThemeStep.8ca01945f2", "Dividers"),
|
||||
label: translate('auto.components.onboarding.ThemeStep.8ca01945f2', 'Dividers'),
|
||||
keys: ['terminalDividerColorDark', 'terminalDividerColorLight']
|
||||
},
|
||||
{ label: translate("auto.components.onboarding.ThemeStep.6c51398942", "Mouse"), keys: ['terminalMouseHideWhileTyping', 'terminalFocusFollowsMouse'] },
|
||||
{ label: translate("auto.components.onboarding.ThemeStep.a4b254779d", "macOS Option key"), keys: ['terminalMacOptionAsAlt'] }
|
||||
{
|
||||
label: translate('auto.components.onboarding.ThemeStep.6c51398942', 'Mouse'),
|
||||
keys: ['terminalMouseHideWhileTyping', 'terminalFocusFollowsMouse']
|
||||
},
|
||||
{
|
||||
label: translate('auto.components.onboarding.ThemeStep.a4b254779d', 'macOS Option key'),
|
||||
keys: ['terminalMacOptionAsAlt']
|
||||
}
|
||||
]
|
||||
return groups.filter(({ keys }) => keys.some((k) => k in diff)).map(({ label }) => label)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,8 +177,16 @@ export default function HostedReviewActions({
|
|||
const confirmed = await confirm({
|
||||
title: `${label} ${shortLabel} ${isGitLab ? '!' : '#'}${review.number}?`,
|
||||
description: isClosing
|
||||
? translate("auto.components.right.sidebar.HostedReviewActions.a3d572a4de", "This will close the {{value0}}.", { value0: reviewLabel })
|
||||
: translate("auto.components.right.sidebar.HostedReviewActions.78f5ff294c", "This will reopen the {{value0}}.", { value0: reviewLabel }),
|
||||
? translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.a3d572a4de',
|
||||
'This will close the {{value0}}.',
|
||||
{ value0: reviewLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.78f5ff294c',
|
||||
'This will reopen the {{value0}}.',
|
||||
{ value0: reviewLabel }
|
||||
),
|
||||
confirmLabel: label,
|
||||
confirmVariant: isClosing ? 'destructive' : 'default'
|
||||
})
|
||||
|
|
@ -202,7 +210,19 @@ export default function HostedReviewActions({
|
|||
setActionError(result.error)
|
||||
toast.error(result.error)
|
||||
} else {
|
||||
toast.success(isClosing ? translate("auto.components.right.sidebar.HostedReviewActions.fa3ee9a515", "{{value0}} closed", { value0: shortLabel }) : translate("auto.components.right.sidebar.HostedReviewActions.377269db6f", "{{value0}} reopened", { value0: shortLabel }))
|
||||
toast.success(
|
||||
isClosing
|
||||
? translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.fa3ee9a515',
|
||||
'{{value0}} closed',
|
||||
{ value0: shortLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.377269db6f',
|
||||
'{{value0}} reopened',
|
||||
{ value0: shortLabel }
|
||||
)
|
||||
)
|
||||
await onRefreshReview()
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -272,7 +292,10 @@ export default function HostedReviewActions({
|
|||
<GitMerge className="size-3.5" />
|
||||
)}
|
||||
{merging
|
||||
? translate("auto.components.right.sidebar.HostedReviewActions.d2ca293f3d", "Working...")
|
||||
? translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.d2ca293f3d',
|
||||
'Working...'
|
||||
)
|
||||
: mergePresentation.directMergeAvailable
|
||||
? mergeMethods.defaultLabel
|
||||
: (mergePresentation.autoMergeAction?.label ?? mergePresentation.label)}
|
||||
|
|
@ -296,8 +319,15 @@ export default function HostedReviewActions({
|
|||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
disabled={menuDisabled}
|
||||
aria-label={translate("auto.components.right.sidebar.HostedReviewActions.2bfaf4379c", "More {{value0}} actions", { value0: reviewLabel })}
|
||||
title={translate("auto.components.right.sidebar.HostedReviewActions.9845a71e17", "More actions")}
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.2bfaf4379c',
|
||||
'More {{value0}} actions',
|
||||
{ value0: reviewLabel }
|
||||
)}
|
||||
title={translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.9845a71e17',
|
||||
'More actions'
|
||||
)}
|
||||
>
|
||||
{stateUpdating === 'closed' ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
|
|
@ -336,7 +366,11 @@ export default function HostedReviewActions({
|
|||
onSelect={() => void handleCloseReview()}
|
||||
>
|
||||
<GitPullRequestClosed className="size-3.5" />
|
||||
{translate("auto.components.right.sidebar.HostedReviewActions.4d5fb5a284", "Close")} {shortLabel}
|
||||
{translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.4d5fb5a284',
|
||||
'Close'
|
||||
)}{' '}
|
||||
{shortLabel}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -363,7 +397,16 @@ export default function HostedReviewActions({
|
|||
) : (
|
||||
<CircleDot className="size-3.5" />
|
||||
)}
|
||||
{stateUpdating === 'open' ? translate("auto.components.right.sidebar.HostedReviewActions.6645ac7dd1", "Reopening...") : translate("auto.components.right.sidebar.HostedReviewActions.3ce211ece6", "Reopen {{value0}}", { value0: shortLabel })}
|
||||
{stateUpdating === 'open'
|
||||
? translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.6645ac7dd1',
|
||||
'Reopening...'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.3ce211ece6',
|
||||
'Reopen {{value0}}',
|
||||
{ value0: shortLabel }
|
||||
)}
|
||||
</Button>
|
||||
{actionError && <div className="text-[10px] text-rose-500 break-words">{actionError}</div>}
|
||||
</div>
|
||||
|
|
@ -385,7 +428,12 @@ export default function HostedReviewActions({
|
|||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
)}
|
||||
{isDeletingWorktree ? translate("auto.components.right.sidebar.HostedReviewActions.eefd50457e", "Deleting...") : translate("auto.components.right.sidebar.HostedReviewActions.e4aca40024", "Delete Workspace")}
|
||||
{isDeletingWorktree
|
||||
? translate('auto.components.right.sidebar.HostedReviewActions.eefd50457e', 'Deleting...')
|
||||
: translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.e4aca40024',
|
||||
'Delete Workspace'
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useActiveWorktree } from '@/store/selectors'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
|
|
@ -8,12 +7,11 @@ import type { SearchFileResult, SearchMatch } from '../../../../shared/types'
|
|||
import { buildSearchRows } from './search-rows'
|
||||
import { cancelRevealFrame, openMatchResult } from './search-match-open'
|
||||
import { SearchHeader } from './SearchHeader'
|
||||
import { FileResultRow, MatchResultRow } from './SearchResultItems'
|
||||
import { SearchResultsPane } from './SearchResultsPane'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
const SEARCH_MAX_RESULTS = 2000
|
||||
const SEARCH_VIRTUAL_OVERSCAN = 12
|
||||
const EMPTY_COLLAPSED_FILES = new Set<string>()
|
||||
|
||||
export default function Search(): React.JSX.Element {
|
||||
|
|
@ -165,38 +163,6 @@ export default function Search(): React.JSX.Element {
|
|||
[deferredSearchResults, fileSearchCollapsedFiles, fileSearchQuery, worktreePath]
|
||||
)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: searchRows.length,
|
||||
getScrollElement: () => resultsScrollRef.current,
|
||||
estimateSize: (index) => {
|
||||
const row = searchRows[index]
|
||||
if (!row) {
|
||||
return 20
|
||||
}
|
||||
// Why: file rows include pt-1.5 (6 px) for inter-group spacing, so
|
||||
// their estimate is taller than match rows.
|
||||
if (row.type === 'file') {
|
||||
return 28
|
||||
}
|
||||
return 20
|
||||
},
|
||||
// Why: paddingEnd adds visible breathing room after the last result row.
|
||||
// paddingStart is unnecessary because each file row already includes
|
||||
// pt-1.5 for inter-group spacing (which also covers the first row).
|
||||
paddingEnd: 8,
|
||||
overscan: SEARCH_VIRTUAL_OVERSCAN,
|
||||
getItemKey: (index) => {
|
||||
const row = searchRows[index]
|
||||
if (!row) {
|
||||
return `missing:${index}`
|
||||
}
|
||||
if (row.type === 'file') {
|
||||
return `file:${row.fileResult.filePath}`
|
||||
}
|
||||
return `match:${row.fileResult.filePath}:${row.match.line}:${row.match.column}:${row.matchIndex}`
|
||||
}
|
||||
})
|
||||
|
||||
// Execute search with debounce — reads fresh state inside setTimeout
|
||||
// to avoid stale closures when options change during debounce
|
||||
const executeSearch = useCallback(
|
||||
|
|
@ -393,79 +359,16 @@ export default function Search(): React.JSX.Element {
|
|||
}}
|
||||
/>
|
||||
|
||||
{/* Why: the summary is rendered outside the virtualizer so it stays
|
||||
pinned at the top while the user scrolls through results. */}
|
||||
{deferredSearchResults && searchRows.length > 0 && (
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b border-border">
|
||||
{deferredSearchResults.totalMatches}{' '}
|
||||
{translate('auto.components.right.sidebar.Search.6aeda362ed', 'result')}
|
||||
{deferredSearchResults.totalMatches !== 1 ? 's' : ''}{' '}
|
||||
{translate('auto.components.right.sidebar.Search.4107975b3a', 'in')}{' '}
|
||||
{deferredSearchResults.files.length}{' '}
|
||||
{translate('auto.components.right.sidebar.Search.0b8104eaf2', 'file')}
|
||||
{deferredSearchResults.files.length !== 1 ? 's' : ''}
|
||||
{deferredSearchResults.truncated &&
|
||||
translate('auto.components.right.sidebar.Search.dcc294f28d', '(results truncated)')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={resultsScrollRef} className="flex-1 min-h-0 overflow-y-auto scrollbar-sleek">
|
||||
{searchRows.length > 0 && (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: virtualizer.getTotalSize()
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = searchRows[virtualRow.index]
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
}}
|
||||
>
|
||||
{row.type === 'file' && (
|
||||
<FileResultRow
|
||||
fileResult={row.fileResult}
|
||||
collapsed={row.collapsed}
|
||||
onToggleCollapse={() => toggleActiveCollapsedFile(row.fileResult.filePath)}
|
||||
/>
|
||||
)}
|
||||
{row.type === 'match' && (
|
||||
<MatchResultRow
|
||||
match={row.match}
|
||||
relativePath={row.fileResult.relativePath}
|
||||
onClick={() => handleMatchClick(row.fileResult, row.match)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fileSearchResults && fileSearchQuery && !fileSearchLoading && (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground text-xs">
|
||||
{translate('auto.components.right.sidebar.Search.d56d140747', 'Press Enter to search')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fileSearchQuery && (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground text-xs">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.Search.1abfb25a66',
|
||||
'Type to search in files'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SearchResultsPane
|
||||
results={deferredSearchResults}
|
||||
hasCommittedResults={fileSearchResults !== null}
|
||||
query={fileSearchQuery}
|
||||
loading={fileSearchLoading}
|
||||
rows={searchRows}
|
||||
scrollRef={resultsScrollRef}
|
||||
onToggleCollapsedFile={toggleActiveCollapsedFile}
|
||||
onMatchClick={handleMatchClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import React from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import type { SearchFileResult, SearchMatch, SearchResult } from '../../../../shared/types'
|
||||
import type { SearchRow } from './search-rows'
|
||||
import { FileResultRow, MatchResultRow } from './SearchResultItems'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const SEARCH_VIRTUAL_OVERSCAN = 12
|
||||
|
||||
type SearchResultsPaneProps = {
|
||||
results: SearchResult | null
|
||||
hasCommittedResults: boolean
|
||||
query: string
|
||||
loading: boolean
|
||||
rows: SearchRow[]
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
onToggleCollapsedFile: (filePath: string) => void
|
||||
onMatchClick: (fileResult: SearchFileResult, match: SearchMatch) => void
|
||||
}
|
||||
|
||||
export function SearchResultsPane({
|
||||
results,
|
||||
hasCommittedResults,
|
||||
query,
|
||||
loading,
|
||||
rows,
|
||||
scrollRef,
|
||||
onToggleCollapsedFile,
|
||||
onMatchClick
|
||||
}: SearchResultsPaneProps): React.JSX.Element {
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: (index) => {
|
||||
const row = rows[index]
|
||||
if (!row) {
|
||||
return 20
|
||||
}
|
||||
// Why: file rows include pt-1.5 (6 px) for inter-group spacing, so
|
||||
// their estimate is taller than match rows.
|
||||
if (row.type === 'file') {
|
||||
return 28
|
||||
}
|
||||
return 20
|
||||
},
|
||||
// Why: paddingEnd adds visible breathing room after the last result row.
|
||||
// paddingStart is unnecessary because each file row already includes
|
||||
// pt-1.5 for inter-group spacing (which also covers the first row).
|
||||
paddingEnd: 8,
|
||||
overscan: SEARCH_VIRTUAL_OVERSCAN,
|
||||
getItemKey: (index) => {
|
||||
const row = rows[index]
|
||||
if (!row) {
|
||||
return `missing:${index}`
|
||||
}
|
||||
if (row.type === 'file') {
|
||||
return `file:${row.fileResult.filePath}`
|
||||
}
|
||||
return `match:${row.fileResult.filePath}:${row.match.line}:${row.match.column}:${row.matchIndex}`
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Why: the summary is rendered outside the virtualizer so it stays
|
||||
pinned at the top while the user scrolls through results. */}
|
||||
{results && rows.length > 0 && (
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b border-border">
|
||||
{results.totalMatches}{' '}
|
||||
{translate('auto.components.right.sidebar.Search.6aeda362ed', 'result')}
|
||||
{results.totalMatches !== 1 ? 's' : ''}{' '}
|
||||
{translate('auto.components.right.sidebar.Search.4107975b3a', 'in')}{' '}
|
||||
{results.files.length}{' '}
|
||||
{translate('auto.components.right.sidebar.Search.0b8104eaf2', 'file')}
|
||||
{results.files.length !== 1 ? 's' : ''}
|
||||
{results.truncated &&
|
||||
translate('auto.components.right.sidebar.Search.dcc294f28d', '(results truncated)')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto scrollbar-sleek">
|
||||
{rows.length > 0 && (
|
||||
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||
>
|
||||
{row.type === 'file' && (
|
||||
<FileResultRow
|
||||
fileResult={row.fileResult}
|
||||
collapsed={row.collapsed}
|
||||
onToggleCollapse={() => onToggleCollapsedFile(row.fileResult.filePath)}
|
||||
/>
|
||||
)}
|
||||
{row.type === 'match' && (
|
||||
<MatchResultRow
|
||||
match={row.match}
|
||||
relativePath={row.fileResult.relativePath}
|
||||
onClick={() => onMatchClick(row.fileResult, row.match)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasCommittedResults && query && !loading && (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground text-xs">
|
||||
{translate('auto.components.right.sidebar.Search.d56d140747', 'Press Enter to search')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!query && (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground text-xs">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.Search.1abfb25a66',
|
||||
'Type to search in files'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'
|
|||
import { useSidebarResize } from '@/hooks/useSidebarResize'
|
||||
import type { ActivityBarPosition } from '@/store/slices/editor'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
||||
import {
|
||||
ContextMenu,
|
||||
|
|
@ -37,6 +38,7 @@ import {
|
|||
} from './right-sidebar-width'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { RightSidebarPanelContent } from './right-sidebar-panel-content'
|
||||
import { useMeasuredWidth } from './right-sidebar-measured-width'
|
||||
import { normalizeRightSidebarRoute } from '@/store/right-sidebar-route'
|
||||
import { AgentSessionHistoryIcon } from './agent-session-history-icon'
|
||||
|
||||
|
|
@ -50,11 +52,6 @@ function RightSidebarInner(): React.JSX.Element {
|
|||
const checksShortcut = useShortcutLabel('sidebar.checks.toggle')
|
||||
const portsShortcut = useShortcutLabel('sidebar.ports.toggle')
|
||||
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
|
||||
const activeWorktree = useAppStore((s) =>
|
||||
rightSidebarOpen && s.activeWorktreeId
|
||||
? (s.getKnownWorktreeById(s.activeWorktreeId) ?? null)
|
||||
: null
|
||||
)
|
||||
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
|
||||
const setRightSidebarWidth = useAppStore((s) => s.setRightSidebarWidth)
|
||||
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
|
||||
|
|
@ -65,10 +62,16 @@ function RightSidebarInner(): React.JSX.Element {
|
|||
const activityBarPosition = useAppStore((s) => s.activityBarPosition)
|
||||
const setActivityBarPosition = useAppStore((s) => s.setActivityBarPosition)
|
||||
const [topActivityStripWidth, setTopActivityStripWidth] = useState<number | null>(null)
|
||||
const activeWorktreeId = useAppStore((s) => (rightSidebarOpen ? s.activeWorktreeId : null))
|
||||
// Why: source control and checks are meaningless for non-git folders.
|
||||
// Hide those tabs so the activity bar only shows relevant actions.
|
||||
const activeWorktree = useAppStore((s) =>
|
||||
activeWorktreeId ? (s.getKnownWorktreeById(activeWorktreeId) ?? null) : null
|
||||
)
|
||||
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
|
||||
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
|
||||
const isFolder =
|
||||
parseWorkspaceKey(activeWorktreeId ?? '')?.type === 'folder' ||
|
||||
(activeRepo ? isFolderRepo(activeRepo) : false)
|
||||
const isSshRepo = Boolean(activeRepo?.connectionId)
|
||||
|
||||
const activityItems = useMemo<ActivityBarItem[]>(
|
||||
|
|
@ -121,6 +124,13 @@ function RightSidebarInner(): React.JSX.Element {
|
|||
const effectiveTab = visibleItems.some((item) => item.id === normalizedActiveTab)
|
||||
? normalizedActiveTab
|
||||
: visibleItems[0].id
|
||||
useEffect(() => {
|
||||
if (effectiveTab !== rightSidebarTab) {
|
||||
// Why: folder workspaces hide git-only panels. Persist the fallback so
|
||||
// panels and activity-button refs do not churn against a hidden tab.
|
||||
setRightSidebarTab(effectiveTab)
|
||||
}
|
||||
}, [effectiveTab, rightSidebarTab, setRightSidebarTab])
|
||||
const selectActivityTab = (tab: typeof effectiveTab): void => {
|
||||
if (tab === 'explorer') {
|
||||
showRightSidebarFiles()
|
||||
|
|
@ -402,31 +412,6 @@ function getWindowWidth(): number | null {
|
|||
return window.innerWidth
|
||||
}
|
||||
|
||||
function useMeasuredWidth(onWidth: (width: number | null) => void) {
|
||||
const observerRef = React.useRef<ResizeObserver | null>(null)
|
||||
|
||||
return React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
|
||||
if (!node || typeof ResizeObserver === 'undefined') {
|
||||
onWidth(node ? node.getBoundingClientRect().width : null)
|
||||
return
|
||||
}
|
||||
|
||||
const updateWidth = (): void => {
|
||||
onWidth(node.getBoundingClientRect().width)
|
||||
}
|
||||
updateWidth()
|
||||
const observer = new ResizeObserver(updateWidth)
|
||||
observer.observe(node)
|
||||
observerRef.current = observer
|
||||
},
|
||||
[onWidth]
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Context Menu for Activity Bar Position ───────────
|
||||
function ActivityBarPositionMenu({
|
||||
currentPosition,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import { useCallback, useRef } from 'react'
|
||||
|
||||
export function useMeasuredWidth(onWidth: (width: number | null) => void) {
|
||||
const observerRef = useRef<ResizeObserver | null>(null)
|
||||
const widthRef = useRef<number | null>(null)
|
||||
|
||||
return useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
|
||||
const commitWidth = (width: number | null): void => {
|
||||
if (Object.is(widthRef.current, width)) {
|
||||
return
|
||||
}
|
||||
widthRef.current = width
|
||||
onWidth(width)
|
||||
}
|
||||
|
||||
if (!node || typeof ResizeObserver === 'undefined') {
|
||||
commitWidth(node ? node.getBoundingClientRect().width : null)
|
||||
return
|
||||
}
|
||||
|
||||
const updateWidth = (): void => {
|
||||
commitWidth(node.getBoundingClientRect().width)
|
||||
}
|
||||
updateWidth()
|
||||
const observer = new ResizeObserver(updateWidth)
|
||||
observer.observe(node)
|
||||
observerRef.current = observer
|
||||
},
|
||||
[onWidth]
|
||||
)
|
||||
}
|
||||
|
|
@ -7,7 +7,13 @@ import { RIGHT_SIDEBAR_HEADER_NO_DRAG_CLASS_NAME } from './right-sidebar-titleba
|
|||
|
||||
const mockAppState = vi.hoisted(() => ({
|
||||
rightSidebarOpen: true,
|
||||
activityBarPosition: 'top' as 'top' | 'side'
|
||||
activityBarPosition: 'top' as 'top' | 'side',
|
||||
activeWorktreeId: 'worktree-1',
|
||||
activeRepo: { id: 'repo-1', kind: 'git', connectionId: null } as {
|
||||
id: string
|
||||
kind: 'git' | 'folder'
|
||||
connectionId: string | null
|
||||
} | null
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useSidebarResize', () => ({
|
||||
|
|
@ -33,6 +39,8 @@ vi.mock('@/store', () => ({
|
|||
setRightSidebarTab: vi.fn(),
|
||||
showRightSidebarFiles: vi.fn(),
|
||||
toggleRightSidebar: vi.fn(),
|
||||
activeWorktreeId: mockAppState.activeWorktreeId,
|
||||
getKnownWorktreeById: () => ({ id: mockAppState.activeWorktreeId, repoId: 'repo-1' }),
|
||||
activityBarPosition: mockAppState.activityBarPosition,
|
||||
setActivityBarPosition: vi.fn(),
|
||||
checksByWorktreeId: {},
|
||||
|
|
@ -42,7 +50,8 @@ vi.mock('@/store', () => ({
|
|||
|
||||
vi.mock('@/store/selectors', () => ({
|
||||
useActiveWorktree: () => ({ id: 'worktree-1', repoId: 'repo-1' }),
|
||||
useRepoById: () => ({ id: 'repo-1', kind: 'git', connectionId: null })
|
||||
useRepoById: () => mockAppState.activeRepo,
|
||||
getWorktreeMapFromState: () => new Map()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
|
|
@ -131,6 +140,8 @@ describe('rendered right sidebar titlebar drag regions', () => {
|
|||
beforeEach(() => {
|
||||
mockAppState.rightSidebarOpen = true
|
||||
mockAppState.activityBarPosition = 'top'
|
||||
mockAppState.activeWorktreeId = 'worktree-1'
|
||||
mockAppState.activeRepo = { id: 'repo-1', kind: 'git', connectionId: null }
|
||||
})
|
||||
|
||||
it('keeps the rendered top activity strip draggable, context-menuable, and only controls no-drag', () => {
|
||||
|
|
@ -187,6 +198,19 @@ describe('rendered right sidebar titlebar drag regions', () => {
|
|||
expect(buttonOpeningTag(markup, 'Toggle right sidebar')).toContain('sidebar-toggle')
|
||||
})
|
||||
|
||||
it('hides git-only activity buttons for folder workspace ids without a backing repo', () => {
|
||||
mockAppState.activeWorktreeId = 'folder:folder-1'
|
||||
mockAppState.activeRepo = null
|
||||
|
||||
const markup = renderToStaticMarkup(<RightSidebar />)
|
||||
|
||||
expect(markup).toContain('aria-label="Explorer')
|
||||
expect(markup).toContain('aria-label="Agents')
|
||||
expect(markup).not.toContain('aria-label="Search')
|
||||
expect(markup).not.toContain('aria-label="Source Control')
|
||||
expect(markup).not.toContain('aria-label="Checks')
|
||||
})
|
||||
|
||||
it('does not render hidden panel content while the sidebar is closed', () => {
|
||||
mockAppState.rightSidebarOpen = false
|
||||
|
||||
|
|
|
|||
|
|
@ -294,7 +294,15 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
|
|||
{ targetGroupId }
|
||||
)
|
||||
setActiveView('terminal')
|
||||
toast.success(translate("auto.components.settings.McpConfigSection.1f3665e35a", "MCP config created"), { description: translate("auto.components.settings.McpConfigSection.9ee215caf6", ".mcp.json") })
|
||||
toast.success(
|
||||
translate('auto.components.settings.McpConfigSection.1f3665e35a', 'MCP config created'),
|
||||
{
|
||||
description: translate(
|
||||
'auto.components.settings.McpConfigSection.9ee215caf6',
|
||||
'.mcp.json'
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(extractIpcErrorMessage(error, 'Failed to create MCP config.'))
|
||||
}
|
||||
|
|
@ -304,12 +312,22 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
|
|||
<section className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">{translate("auto.components.settings.McpConfigSection.55eea3ef47", "MCP Configs")}</h3>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{translate('auto.components.settings.McpConfigSection.55eea3ef47', 'MCP Configs')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate("auto.components.settings.McpConfigSection.96f5609b04", "Inspect MCP server definitions that agents can use while working in this repo.")}</p>
|
||||
{translate(
|
||||
'auto.components.settings.McpConfigSection.96f5609b04',
|
||||
'Inspect MCP server definitions that agents can use while working in this repo.'
|
||||
)}
|
||||
</p>
|
||||
{repo.connectionId ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate("auto.components.settings.McpConfigSection.6bac9ddfc6", "SSH repos are read through the remote filesystem. Starter creation is limited to the workspace root config.")}</p>
|
||||
{translate(
|
||||
'auto.components.settings.McpConfigSection.6bac9ddfc6',
|
||||
'SSH repos are read through the remote filesystem. Starter creation is limited to the workspace root config.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
|
|
@ -317,7 +335,10 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
|
|||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => void loadConfigs()}
|
||||
aria-label={translate("auto.components.settings.McpConfigSection.f34c152dc0", "Refresh MCP configs")}
|
||||
aria-label={translate(
|
||||
'auto.components.settings.McpConfigSection.f34c152dc0',
|
||||
'Refresh MCP configs'
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
|
|
@ -333,7 +354,15 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
|
|||
onClick={() => void handleCreateStarter()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{createConfirm ? translate("auto.components.settings.McpConfigSection.0a5c1ead54", "Create empty config") : translate("auto.components.settings.McpConfigSection.82436439eb", "Add MCP config")}
|
||||
{createConfirm
|
||||
? translate(
|
||||
'auto.components.settings.McpConfigSection.0a5c1ead54',
|
||||
'Create empty config'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.McpConfigSection.82436439eb',
|
||||
'Add MCP config'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -342,8 +371,10 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
|
|||
<div className="rounded-md border border-border/50 bg-muted/20">
|
||||
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{detectedCount} {translate("auto.components.settings.McpConfigSection.251b96564a", "detected ·")} {serverCount}{' '}
|
||||
{translate("auto.components.settings.McpConfigSection.3b224167ff", "server")}
|
||||
{detectedCount}{' '}
|
||||
{translate('auto.components.settings.McpConfigSection.251b96564a', 'detected ·')}{' '}
|
||||
{serverCount}{' '}
|
||||
{translate('auto.components.settings.McpConfigSection.3b224167ff', 'server')}
|
||||
{serverCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
{loading ? <LoaderCircle className="size-3.5 animate-spin" /> : null}
|
||||
|
|
@ -360,7 +391,11 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
|
|||
<span>{inspectionUnavailableMessage}</span>
|
||||
) : (
|
||||
<span>
|
||||
{translate("auto.components.settings.McpConfigSection.b900cd6282", "No MCP config found. Add an empty workspace config when you want this repo to define its own MCP servers.")}</span>
|
||||
{translate(
|
||||
'auto.components.settings.McpConfigSection.b900cd6282',
|
||||
'No MCP config found. Add an empty workspace config when you want this repo to define its own MCP servers.'
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -377,7 +412,9 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
|
|||
|
||||
{missingConfigs.length > 0 && !inspectionUnavailable ? (
|
||||
<div className="space-y-1.5 border-t border-border/50 px-3 py-2">
|
||||
<p className="text-[11px] text-muted-foreground">{translate("auto.components.settings.McpConfigSection.4d16a0d9ac", "Checked")}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{translate('auto.components.settings.McpConfigSection.4d16a0d9ac', 'Checked')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{missingConfigs.map((config) => (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -102,21 +102,23 @@ function renderNestedStep(repoCount: number): string {
|
|||
}
|
||||
|
||||
describe('AddRepoDialogStepContent nested imports', () => {
|
||||
it('uses the first-import nested repo action when no repos exist yet', () => {
|
||||
it('asks the monorepo question when no repos exist yet', () => {
|
||||
const html = renderNestedStep(0)
|
||||
|
||||
expect(html).toContain('>Import</button>')
|
||||
expect(html).not.toContain('Import as group')
|
||||
expect(html).not.toContain('Import separately')
|
||||
expect(html).not.toContain('aria-label="Group name"')
|
||||
expect(html).toContain('Is this a monorepo?')
|
||||
expect(html).toContain('aria-label="Monorepo name"')
|
||||
expect(html).toContain('Yes, import as monorepo')
|
||||
expect(html).toContain('No, import separately')
|
||||
expect(html).not.toContain('>Import</button>')
|
||||
})
|
||||
|
||||
it('shows group import controls after a repo already exists', () => {
|
||||
it('shows the same monorepo import controls after a repo already exists', () => {
|
||||
const html = renderNestedStep(1)
|
||||
|
||||
expect(html).toContain('aria-label="Group name"')
|
||||
expect(html).toContain('Import separately')
|
||||
expect(html).toContain('Import as group')
|
||||
expect(html).toContain('Is this a monorepo?')
|
||||
expect(html).toContain('aria-label="Monorepo name"')
|
||||
expect(html).toContain('Yes, import as monorepo')
|
||||
expect(html).toContain('No, import separately')
|
||||
expect(html).not.toContain('>Import</button>')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -209,7 +209,6 @@ export function AddRepoDialogStepContent({
|
|||
scan={nestedScan}
|
||||
groupName={nestedGroupName}
|
||||
selectedPaths={nestedSelectedPaths}
|
||||
isFirstRepoImport={repoCount === 0}
|
||||
isAdding={isAdding}
|
||||
scanInProgress={nestedScanInProgress}
|
||||
onGroupNameChange={onNestedGroupNameChange}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, type ComponentProps } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AddRepoNestedImportStep } from './AddRepoNestedImportStep'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Dialog } from '@/components/ui/dialog'
|
||||
|
|
@ -22,34 +26,65 @@ const scan: NestedRepoScanResult = {
|
|||
timeoutMs: null
|
||||
}
|
||||
|
||||
function renderStepMarkup(
|
||||
overrides: Partial<ComponentProps<typeof AddRepoNestedImportStep>> = {}
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<Dialog open>
|
||||
<AddRepoNestedImportStep
|
||||
scan={scan}
|
||||
groupName=""
|
||||
selectedPaths={new Set(scan.repos.map((repo) => repo.path))}
|
||||
isAdding={false}
|
||||
scanInProgress={false}
|
||||
onGroupNameChange={vi.fn()}
|
||||
onSelectedPathsChange={vi.fn()}
|
||||
onImport={vi.fn()}
|
||||
onStopScan={vi.fn()}
|
||||
{...overrides}
|
||||
/>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function findButton(container: HTMLElement, label: string): HTMLButtonElement {
|
||||
const button = Array.from(container.querySelectorAll('button')).find((entry) =>
|
||||
entry.textContent?.includes(label)
|
||||
)
|
||||
if (!button) {
|
||||
throw new Error(`Button not found: ${label}`)
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
describe('AddRepoNestedImportStep', () => {
|
||||
it('allows grouped import with a blank group name and flat collision labels', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<Dialog open>
|
||||
<AddRepoNestedImportStep
|
||||
scan={scan}
|
||||
groupName=""
|
||||
selectedPaths={new Set(scan.repos.map((repo) => repo.path))}
|
||||
isFirstRepoImport={false}
|
||||
isAdding={false}
|
||||
scanInProgress={false}
|
||||
onGroupNameChange={vi.fn()}
|
||||
onSelectedPathsChange={vi.fn()}
|
||||
onImport={vi.fn()}
|
||||
onStopScan={vi.fn()}
|
||||
/>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
)
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
root = null
|
||||
}
|
||||
container?.remove()
|
||||
container = null
|
||||
})
|
||||
|
||||
it('asks whether the selected folder is a monorepo', () => {
|
||||
const html = renderStepMarkup()
|
||||
|
||||
expect(html).toContain('Import repositories from folder')
|
||||
expect(html).toContain('Found 3 repositories in')
|
||||
expect(html).toContain('/workspace/platform')
|
||||
expect(html).toContain('aria-label="Group name"')
|
||||
expect(html).toContain('aria-label="What is a group name?"')
|
||||
expect(html).toContain('Import separately')
|
||||
expect(html).toContain('Import as group')
|
||||
expect(html).toContain('aria-label="Monorepo name"')
|
||||
expect(html).toContain('aria-label="What is a monorepo name?"')
|
||||
expect(html).toContain('Is this a monorepo?')
|
||||
expect(html).toContain('Choose this if these projects belong together')
|
||||
expect(html).toContain('Orca will group them and let you work from the parent folder')
|
||||
expect(html).toContain('No, import separately')
|
||||
expect(html).toContain('Yes, import as monorepo')
|
||||
expect(html).toContain('payments/api')
|
||||
expect(html).toContain('billing/api')
|
||||
expect(html).not.toContain('disabled=""')
|
||||
|
|
@ -57,33 +92,49 @@ describe('AddRepoNestedImportStep', () => {
|
|||
expect(html).not.toContain('Project group')
|
||||
})
|
||||
|
||||
it('shows a single primary import action for a first repo import', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<Dialog open>
|
||||
<AddRepoNestedImportStep
|
||||
scan={scan}
|
||||
groupName=""
|
||||
selectedPaths={new Set(scan.repos.map((repo) => repo.path))}
|
||||
isFirstRepoImport={true}
|
||||
isAdding={false}
|
||||
scanInProgress={false}
|
||||
onGroupNameChange={vi.fn()}
|
||||
onSelectedPathsChange={vi.fn()}
|
||||
onImport={vi.fn()}
|
||||
onStopScan={vi.fn()}
|
||||
/>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
)
|
||||
it('disables both import actions while scanning', () => {
|
||||
const html = renderStepMarkup({ scanInProgress: true })
|
||||
|
||||
expect(html).toContain('Found 3 repositories in')
|
||||
expect(html).toContain('data-variant="default"')
|
||||
expect(html).toContain('>Import</button>')
|
||||
expect(html).not.toContain('aria-label="Group name"')
|
||||
expect(html).not.toContain('What is a group name?')
|
||||
expect(html).not.toContain('Import as group')
|
||||
expect(html).not.toContain('Import separately')
|
||||
expect(html).not.toContain('>Back</button>')
|
||||
expect(html).toContain('Is this a monorepo?')
|
||||
expect(html).toContain('No, import separately')
|
||||
expect(html).toContain('Yes, import as monorepo')
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>No, import separately<\/button>/)
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as monorepo<\/button>/)
|
||||
})
|
||||
|
||||
it('maps the monorepo choice to grouped import and the non-monorepo choice to separate import', () => {
|
||||
const onImport = vi.fn()
|
||||
const host = document.createElement('div')
|
||||
container = host
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<Dialog open>
|
||||
<AddRepoNestedImportStep
|
||||
scan={scan}
|
||||
groupName=""
|
||||
selectedPaths={new Set(scan.repos.map((repo) => repo.path))}
|
||||
isAdding={false}
|
||||
scanInProgress={false}
|
||||
onGroupNameChange={vi.fn()}
|
||||
onSelectedPathsChange={vi.fn()}
|
||||
onImport={onImport}
|
||||
onStopScan={vi.fn()}
|
||||
/>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
findButton(host, 'Yes, import as monorepo').click()
|
||||
findButton(host, 'No, import separately').click()
|
||||
})
|
||||
|
||||
expect(onImport).toHaveBeenNthCalledWith(1, 'group')
|
||||
expect(onImport).toHaveBeenNthCalledWith(2, 'separate')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ type AddRepoNestedImportStepProps = {
|
|||
scan: NestedRepoScanResult
|
||||
groupName: string
|
||||
selectedPaths: Set<string>
|
||||
isFirstRepoImport: boolean
|
||||
isAdding: boolean
|
||||
scanInProgress: boolean
|
||||
onGroupNameChange: (value: string) => void
|
||||
|
|
@ -28,7 +27,6 @@ export function AddRepoNestedImportStep({
|
|||
scan,
|
||||
groupName,
|
||||
selectedPaths,
|
||||
isFirstRepoImport,
|
||||
isAdding,
|
||||
scanInProgress,
|
||||
onGroupNameChange,
|
||||
|
|
@ -38,9 +36,22 @@ export function AddRepoNestedImportStep({
|
|||
}: AddRepoNestedImportStepProps): React.JSX.Element {
|
||||
const folderName = getRuntimePathBasename(scan.selectedPath) || scan.selectedPath
|
||||
const groupNameInputId = useId()
|
||||
const repoCountLabel = `${scan.repos.length} ${
|
||||
scan.repos.length === 1 ? 'repository' : 'repositories'
|
||||
}`
|
||||
const repoCountLabel =
|
||||
scan.repos.length === 1
|
||||
? translate('auto.components.sidebar.AddRepoNestedImportStep.8401a7a0d0', '1 repository')
|
||||
: translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.d4f1df62ef',
|
||||
'{{value0}} repositories',
|
||||
{ value0: scan.repos.length }
|
||||
)
|
||||
const foundSentence = translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.b4263a2ac4',
|
||||
'Found {{value0}} in {{value1}}.',
|
||||
{
|
||||
value0: repoCountLabel,
|
||||
value1: scan.selectedPath
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -56,17 +67,11 @@ export function AddRepoNestedImportStep({
|
|||
<DialogDescription className="min-w-0 truncate">
|
||||
{scanInProgress
|
||||
? translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.220dd32d83',
|
||||
'Scanning...'
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.24eda6c8b2',
|
||||
'Scanning... {{value0}}',
|
||||
{ value0: foundSentence }
|
||||
)
|
||||
: null}
|
||||
{translate('auto.components.sidebar.AddRepoNestedImportStep.4df0d08cc5', 'Found')}{' '}
|
||||
{repoCountLabel}{' '}
|
||||
{translate('auto.components.sidebar.AddRepoNestedImportStep.5f857ba8e6', 'in')}{' '}
|
||||
<span className="font-mono text-[11px] text-foreground" title={scan.selectedPath}>
|
||||
{scan.selectedPath}
|
||||
</span>
|
||||
.
|
||||
: foundSentence}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
|
@ -82,77 +87,84 @@ export function AddRepoNestedImportStep({
|
|||
{scanInProgress || scan.truncated || scan.timedOut || scan.stopped ? (
|
||||
<NestedRepoScanLimitNotice scan={scan} />
|
||||
) : null}
|
||||
{/* Why: first-time import uses one flat action because it is easier for new users to understand. */}
|
||||
{!isFirstRepoImport ? (
|
||||
<div className="min-w-0 shrink-0 space-y-1">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Label htmlFor={groupNameInputId} className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.40199ef7b3',
|
||||
'Group name'
|
||||
)}
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.787412361a',
|
||||
'What is a group name?'
|
||||
)}
|
||||
className="size-5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<CircleHelp className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4} className="max-w-64">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.b20bb7c24f',
|
||||
'Keeps these repos together in one group. Best for related repos like microservices.'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
id={groupNameInputId}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.40199ef7b3',
|
||||
'Group name'
|
||||
<div className="min-w-0 shrink-0 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.fb33359f69',
|
||||
'Is this a monorepo?'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.d75170194e',
|
||||
'Choose this if these projects belong together. Orca will group them and let you work from the parent folder.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0 shrink-0 space-y-1">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Label htmlFor={groupNameInputId} className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.39d51212cc',
|
||||
'Monorepo name'
|
||||
)}
|
||||
value={groupName}
|
||||
onChange={(event) => onGroupNameChange(event.target.value)}
|
||||
disabled={isAdding || scanInProgress}
|
||||
className="h-9 min-w-0"
|
||||
placeholder={folderName}
|
||||
/>
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.e907ec8935',
|
||||
'What is a monorepo name?'
|
||||
)}
|
||||
className="size-5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<CircleHelp className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4} className="max-w-64">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.b20bb7c24f',
|
||||
'Keeps these repos together in one group. Best for related repos like microservices.'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
id={groupNameInputId}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.39d51212cc',
|
||||
'Monorepo name'
|
||||
)}
|
||||
value={groupName}
|
||||
onChange={(event) => onGroupNameChange(event.target.value)}
|
||||
disabled={isAdding || scanInProgress}
|
||||
className="h-9 min-w-0"
|
||||
placeholder={folderName}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
onClick={() => onImport('separate')}
|
||||
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
|
||||
variant={isFirstRepoImport ? 'default' : 'outline'}
|
||||
variant="outline"
|
||||
>
|
||||
{isFirstRepoImport
|
||||
? translate('auto.components.sidebar.AddRepoNestedImportStep.cf9d382ca1', 'Import')
|
||||
: translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.5b2e6fe3c8',
|
||||
'Import separately'
|
||||
)}
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.aa0247680d',
|
||||
'No, import separately'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onImport('group')}
|
||||
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.a0bc4d1f8e',
|
||||
'Yes, import as monorepo'
|
||||
)}
|
||||
</Button>
|
||||
{!isFirstRepoImport ? (
|
||||
<Button
|
||||
onClick={() => onImport('group')}
|
||||
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.c157f31a95',
|
||||
'Import as group'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,408 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import AgentSettingsDialog from '@/components/agent/AgentSettingsDialog'
|
||||
import NewWorkspaceComposerCard from '@/components/NewWorkspaceComposerCard'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { useDetectedAgents } from '@/hooks/useDetectedAgents'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getLinkedWorkItemProvider, type LinkedWorkItemSummary } from '@/lib/new-workspace'
|
||||
import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard'
|
||||
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||
import {
|
||||
pickQuickWorkspaceAgent,
|
||||
resolveQuickWorkspaceAgentSelection
|
||||
} from '@/lib/quick-workspace-agent-selection'
|
||||
import { getSelectedRepoSshGate, isSshConnectInProgress } from '@/lib/new-workspace-ssh-gate'
|
||||
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
|
||||
import type {
|
||||
GitHubWorkItem,
|
||||
GitLabWorkItem,
|
||||
LinearIssue,
|
||||
ProjectGroup,
|
||||
TuiAgent
|
||||
} from '../../../../shared/types'
|
||||
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getFolderSourceRepos,
|
||||
getLinkedItemDisplayName,
|
||||
getSmartNameSelection,
|
||||
toGitHubLinkedWorkItem,
|
||||
toGitLabLinkedWorkItem,
|
||||
toLinearLinkedWorkItem
|
||||
} from './folder-workspace-composer-helpers'
|
||||
import { useFolderWorkspaceComposerPathStatus } from './folder-workspace-composer-path-status'
|
||||
import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit'
|
||||
|
||||
type FolderWorkspaceComposerDialogProps = {
|
||||
projectGroup: ProjectGroup | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function FolderWorkspaceComposerDialog({
|
||||
projectGroup,
|
||||
open,
|
||||
onOpenChange
|
||||
}: FolderWorkspaceComposerDialogProps): React.JSX.Element {
|
||||
const { createFolderWorkspace, projectGroups, repos, settings, sshConnectionStates } =
|
||||
useAppStore(
|
||||
useShallow((s) => ({
|
||||
createFolderWorkspace: s.createFolderWorkspace,
|
||||
projectGroups: s.projectGroups,
|
||||
repos: s.repos,
|
||||
settings: s.settings,
|
||||
sshConnectionStates: s.sshConnectionStates
|
||||
}))
|
||||
)
|
||||
const { pathStatusBlocksCreate, pathStatusProjectError } = useFolderWorkspaceComposerPathStatus(
|
||||
projectGroup,
|
||||
open
|
||||
)
|
||||
const sourceRepos = useMemo(
|
||||
() => getFolderSourceRepos(repos, projectGroups, projectGroup),
|
||||
[projectGroup, projectGroups, repos]
|
||||
)
|
||||
const [repoId, setRepoId] = useState('')
|
||||
const selectedRepo = sourceRepos.find((repo) => repo.id === repoId) ?? null
|
||||
const selectedRepoConnectionId =
|
||||
selectedRepo?.connectionId ??
|
||||
(sourceRepos.length === 0 ? (projectGroup?.connectionId ?? null) : null)
|
||||
const selectedRepoSshState = selectedRepoConnectionId
|
||||
? (sshConnectionStates.get(selectedRepoConnectionId) ?? null)
|
||||
: null
|
||||
const { selectedRepoSshStatus, selectedRepoRequiresConnection, selectedRepoConnectInProgress } =
|
||||
getSelectedRepoSshGate({
|
||||
connectionId: selectedRepoConnectionId,
|
||||
status: selectedRepoSshState?.status ?? null
|
||||
})
|
||||
const { detectedIds } = useDetectedAgents(null)
|
||||
const detectedAgentIds = useMemo(() => (detectedIds ? new Set(detectedIds) : null), [detectedIds])
|
||||
const [name, setName] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [linkedWorkItem, setLinkedWorkItem] = useState<LinkedWorkItemSummary | null>(null)
|
||||
const [quickAgentOverride, setQuickAgentOverride] = useState<TuiAgent | null | undefined>(
|
||||
undefined
|
||||
)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [agentSettingsOpen, setAgentSettingsOpen] = useState(false)
|
||||
const lastAutoNameRef = useRef('')
|
||||
const composerRef = useRef<HTMLDivElement | null>(null)
|
||||
const nameInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
setRepoId(sourceRepos[0]?.id ?? '')
|
||||
setName('')
|
||||
setNote('')
|
||||
setLinkedWorkItem(null)
|
||||
setQuickAgentOverride(undefined)
|
||||
setAdvancedOpen(false)
|
||||
setSubmitting(false)
|
||||
lastAutoNameRef.current = ''
|
||||
}, [open, projectGroup?.id, sourceRepos])
|
||||
|
||||
const preferredQuickAgent = useMemo<TuiAgent | null>(
|
||||
() =>
|
||||
pickQuickWorkspaceAgent(
|
||||
settings?.defaultTuiAgent,
|
||||
detectedAgentIds,
|
||||
settings?.disabledTuiAgents
|
||||
),
|
||||
[detectedAgentIds, settings?.defaultTuiAgent, settings?.disabledTuiAgents]
|
||||
)
|
||||
const resolvedQuickAgentSelection = resolveQuickWorkspaceAgentSelection({
|
||||
quickAgentOverride,
|
||||
preferredQuickAgent,
|
||||
detectedAgentIds,
|
||||
disabledTuiAgents: settings?.disabledTuiAgents
|
||||
})
|
||||
if (resolvedQuickAgentSelection.quickAgentOverride !== quickAgentOverride) {
|
||||
setQuickAgentOverride(resolvedQuickAgentSelection.quickAgentOverride)
|
||||
}
|
||||
const quickAgent = resolvedQuickAgentSelection.quickAgent
|
||||
|
||||
const applyLinkedWorkItem = useCallback(
|
||||
(item: LinkedWorkItemSummary): void => {
|
||||
setLinkedWorkItem(item)
|
||||
const nextName = getLinkedItemDisplayName(item)
|
||||
if (
|
||||
nextName &&
|
||||
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
|
||||
) {
|
||||
setName(nextName)
|
||||
lastAutoNameRef.current = nextName
|
||||
}
|
||||
},
|
||||
[name]
|
||||
)
|
||||
|
||||
const handleRepoChange = useCallback((nextRepoId: string): void => {
|
||||
setRepoId(nextRepoId)
|
||||
setLinkedWorkItem((current) => {
|
||||
const provider = current ? getLinkedWorkItemProvider(current) : null
|
||||
return provider === 'github' || provider === 'gitlab' ? null : current
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSmartGitHubItemSelect = useCallback(
|
||||
(item: GitHubWorkItem): void => {
|
||||
applyLinkedWorkItem(toGitHubLinkedWorkItem(item))
|
||||
},
|
||||
[applyLinkedWorkItem]
|
||||
)
|
||||
|
||||
const handleSmartGitLabItemSelect = useCallback(
|
||||
(item: GitLabWorkItem): void => {
|
||||
applyLinkedWorkItem(toGitLabLinkedWorkItem(item))
|
||||
},
|
||||
[applyLinkedWorkItem]
|
||||
)
|
||||
|
||||
const handleSmartLinearIssueSelect = useCallback(
|
||||
(issue: LinearIssue): void => {
|
||||
applyLinkedWorkItem(toLinearLinkedWorkItem(issue))
|
||||
},
|
||||
[applyLinkedWorkItem]
|
||||
)
|
||||
|
||||
const handleClearSmartNameSelection = useCallback((): void => {
|
||||
setLinkedWorkItem(null)
|
||||
if (name === lastAutoNameRef.current) {
|
||||
setName('')
|
||||
lastAutoNameRef.current = ''
|
||||
}
|
||||
}, [name])
|
||||
|
||||
const handleQuickAgentChange = useCallback((agent: TuiAgent | null): void => {
|
||||
setQuickAgentOverride(agent)
|
||||
}, [])
|
||||
|
||||
const onConnectSelectedRepo = useCallback(async (): Promise<void> => {
|
||||
if (!selectedRepoConnectionId) {
|
||||
return
|
||||
}
|
||||
const liveStatus = useAppStore
|
||||
.getState()
|
||||
.sshConnectionStates.get(selectedRepoConnectionId)?.status
|
||||
if (liveStatus === 'connected' || isSshConnectInProgress(liveStatus ?? null)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await window.api.ssh.connect({ targetId: selectedRepoConnectionId })
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.sidebar.FolderWorkspaceComposerDialog.connectFailed',
|
||||
'Failed to connect to project.'
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [selectedRepoConnectionId])
|
||||
|
||||
const handleCreate = useCallback(async (): Promise<void> => {
|
||||
if (
|
||||
!projectGroup?.parentPath ||
|
||||
submitting ||
|
||||
pathStatusBlocksCreate ||
|
||||
selectedRepoRequiresConnection
|
||||
) {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await submitFolderWorkspaceCreate({
|
||||
projectGroup,
|
||||
name,
|
||||
lastAutoName: lastAutoNameRef.current,
|
||||
linkedWorkItem,
|
||||
note,
|
||||
quickAgent,
|
||||
autoRenameBranchFromWork: settings?.autoRenameBranchFromWork,
|
||||
agentCmdOverrides: settings?.agentCmdOverrides,
|
||||
createFolderWorkspace,
|
||||
onOpenChange
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [
|
||||
createFolderWorkspace,
|
||||
linkedWorkItem,
|
||||
name,
|
||||
note,
|
||||
onOpenChange,
|
||||
projectGroup,
|
||||
quickAgent,
|
||||
settings?.agentCmdOverrides,
|
||||
settings?.autoRenameBranchFromWork,
|
||||
submitting,
|
||||
pathStatusBlocksCreate,
|
||||
selectedRepoRequiresConnection
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'Enter' && event.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
event.preventDefault()
|
||||
target.blur()
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
if (!isScreenSubmitShortcut(event)) {
|
||||
return
|
||||
}
|
||||
if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current) || submitting) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
void handleCreate()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [handleCreate, onOpenChange, open, submitting])
|
||||
|
||||
const smartNameSelection = useMemo(() => getSmartNameSelection(linkedWorkItem), [linkedWorkItem])
|
||||
const emptySourceProjectMessage =
|
||||
sourceRepos.length === 0
|
||||
? translate(
|
||||
'auto.components.sidebar.FolderWorkspaceComposerDialog.noRepos',
|
||||
'Add a Git project under this folder to attach GitHub or GitLab tasks.'
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex max-h-[calc(100vh-2rem)] flex-col overflow-hidden sm:max-w-lg"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
const content = event.currentTarget as HTMLElement
|
||||
const trigger = content.querySelector<HTMLElement>(
|
||||
'[data-repo-combobox-root="true"][role="combobox"]'
|
||||
)
|
||||
trigger?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="gap-1">
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{translate(
|
||||
'auto.components.sidebar.FolderWorkspaceComposerDialog.title',
|
||||
'Create Folder Workspace'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{projectGroup?.parentPath ?? ''}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<NewWorkspaceComposerCard
|
||||
containerClassName="min-h-0 flex-1 overflow-y-auto px-1 scrollbar-sleek"
|
||||
composerRef={composerRef}
|
||||
nameInputRef={nameInputRef}
|
||||
quickAgent={quickAgent}
|
||||
onQuickAgentChange={handleQuickAgentChange}
|
||||
eligibleRepos={sourceRepos}
|
||||
repoId={repoId}
|
||||
selectedRepoIsGit={true}
|
||||
onRepoChange={handleRepoChange}
|
||||
primaryActionLabel={
|
||||
quickAgent
|
||||
? translate(
|
||||
'auto.components.sidebar.FolderWorkspaceComposerDialog.createStart',
|
||||
'Create & Start Agent'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.FolderWorkspaceComposerDialog.create',
|
||||
'Create Workspace'
|
||||
)
|
||||
}
|
||||
projectLabel={translate(
|
||||
'auto.components.sidebar.FolderWorkspaceComposerDialog.sourceProject',
|
||||
'Task Source'
|
||||
)}
|
||||
projectPlaceholder={translate(
|
||||
'auto.components.sidebar.FolderWorkspaceComposerDialog.chooseSourceProject',
|
||||
'Choose task source'
|
||||
)}
|
||||
emptyProjectMessage={emptySourceProjectMessage ?? undefined}
|
||||
showAddProjectButton={false}
|
||||
name={name}
|
||||
onNameValueChange={setName}
|
||||
onSmartGitHubItemSelect={handleSmartGitHubItemSelect}
|
||||
onSmartGitLabItemSelect={handleSmartGitLabItemSelect}
|
||||
onSmartBranchSelect={() => {}}
|
||||
onSmartLinearIssueSelect={handleSmartLinearIssueSelect}
|
||||
smartNameSelection={smartNameSelection}
|
||||
onClearSmartNameSelection={handleClearSmartNameSelection}
|
||||
forkPushWarning={null}
|
||||
detectedAgentIds={detectedAgentIds}
|
||||
onOpenAgentSettings={() => setAgentSettingsOpen(true)}
|
||||
advancedOpen={advancedOpen}
|
||||
onToggleAdvanced={() => setAdvancedOpen((value) => !value)}
|
||||
createDisabled={
|
||||
submitting ||
|
||||
!projectGroup?.parentPath ||
|
||||
pathStatusBlocksCreate ||
|
||||
selectedRepoRequiresConnection
|
||||
}
|
||||
projectError={pathStatusProjectError}
|
||||
creating={submitting}
|
||||
onCreate={() => void handleCreate()}
|
||||
note={note}
|
||||
onNoteChange={setNote}
|
||||
setupConfig={null}
|
||||
requiresExplicitSetupChoice={false}
|
||||
setupDecision={null}
|
||||
onSetupDecisionChange={() => {}}
|
||||
shouldWaitForSetupCheck={false}
|
||||
resolvedSetupDecision={null}
|
||||
createError={null}
|
||||
selectedRepoConnectionId={selectedRepoConnectionId}
|
||||
selectedRepoSshStatus={selectedRepoSshStatus as SshConnectionStatus | null}
|
||||
selectedRepoRequiresConnection={selectedRepoRequiresConnection}
|
||||
selectedRepoConnectInProgress={selectedRepoConnectInProgress}
|
||||
onConnectSelectedRepo={onConnectSelectedRepo}
|
||||
branchesEnabled={false}
|
||||
setupControlsEnabled={false}
|
||||
canUseSparseCheckout={false}
|
||||
sparsePresets={[]}
|
||||
sparseSelectedPresetId={null}
|
||||
onSparseSelectPreset={() => {}}
|
||||
sparseControlsEnabled={false}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AgentSettingsDialog open={agentSettingsOpen} onOpenChange={setAgentSettingsOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@ describe('WorktreeCard quick actions', () => {
|
|||
expect(markup).toContain('data-worktree-card-meta-row=""')
|
||||
})
|
||||
|
||||
it('renders folder kind in the detailed metadata row', () => {
|
||||
it('renders folder kind and directory in the detailed metadata row', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard
|
||||
worktree={makeWorktree({ displayName: 'Docs folder', branch: '' })}
|
||||
|
|
@ -168,6 +168,27 @@ describe('WorktreeCard quick actions', () => {
|
|||
|
||||
expect(markup).toContain('Docs folder')
|
||||
expect(markup).toContain('>Folder</span>')
|
||||
expect(markup).toContain('>quick-action</span>')
|
||||
expect(markup).toContain('data-worktree-card-meta-row=""')
|
||||
})
|
||||
|
||||
it('renders synthetic folder workspace directory in the detailed metadata row', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard
|
||||
worktree={makeWorktree({
|
||||
id: 'folder:folder-1',
|
||||
displayName: 'Docs folder',
|
||||
branch: '',
|
||||
path: '/repo/worktrees/quick-action'
|
||||
})}
|
||||
repo={undefined}
|
||||
isActive={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('Docs folder')
|
||||
expect(markup).toContain('>Folder</span>')
|
||||
expect(markup).toContain('>quick-action</span>')
|
||||
expect(markup).toContain('data-worktree-card-meta-row=""')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import {
|
|||
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
|
||||
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
|
||||
type WorktreeCardProps = {
|
||||
worktree: Worktree
|
||||
|
|
@ -107,6 +108,12 @@ function isWebClient(): boolean {
|
|||
return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__)
|
||||
}
|
||||
|
||||
function getDirectoryName(folderPath: string): string {
|
||||
const normalized = folderPath.replace(/[\\/]+$/, '')
|
||||
const parts = normalized.split(/[\\/]+/)
|
||||
return parts.at(-1) || normalized || folderPath
|
||||
}
|
||||
|
||||
// Why: the pinned repo icon and the compact inline badge share one chip shell;
|
||||
// keep the box + tooltip identical so both repo cues read as the same affordance.
|
||||
function RepoIdentityChip({
|
||||
|
|
@ -165,6 +172,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
const openModal = useAppStore((s) => s.openModal)
|
||||
const openTaskPage = useAppStore((s) => s.openTaskPage)
|
||||
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
|
||||
const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace)
|
||||
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
|
||||
const renamingWorktreeId = useAppStore((s) => s.renamingWorktreeId)
|
||||
const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId)
|
||||
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
|
||||
|
|
@ -247,7 +256,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
const gitIdentityDisplay = getWorktreeGitIdentityDisplay(worktree)
|
||||
const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null
|
||||
const branch = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
|
||||
const isFolder = repo ? isFolderRepo(repo) : false
|
||||
const workspaceScope = parseWorkspaceKey(worktree.id)
|
||||
const folderWorkspaceId =
|
||||
workspaceScope?.type === 'folder' ? workspaceScope.folderWorkspaceId : null
|
||||
const isFolder = repo ? isFolderRepo(repo) : folderWorkspaceId !== null
|
||||
const hostedReviewCacheKey =
|
||||
repo && branch
|
||||
? getHostedReviewCacheKey(repo.path, branch, settings, repo.id, repo.connectionId)
|
||||
|
|
@ -555,10 +567,27 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (showDeleteQuickAction) {
|
||||
if (folderWorkspaceId) {
|
||||
void deleteFolderWorkspace(folderWorkspaceId).then((deleted) => {
|
||||
if (
|
||||
deleted &&
|
||||
useAppStore.getState().activeWorktreeId === folderWorkspaceKey(folderWorkspaceId)
|
||||
) {
|
||||
setActiveWorktree(null)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
runWorktreeDelete(worktree.id)
|
||||
}
|
||||
},
|
||||
[showDeleteQuickAction, worktree.id]
|
||||
[
|
||||
deleteFolderWorkspace,
|
||||
folderWorkspaceId,
|
||||
setActiveWorktree,
|
||||
showDeleteQuickAction,
|
||||
worktree.id
|
||||
]
|
||||
)
|
||||
const handlePendingFirstAgentMessageRenameInfo = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
|
|
@ -965,6 +994,17 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
onBeginEditingConsumed={() => setRenamingWorktreeId(null)}
|
||||
/>
|
||||
|
||||
{isFolder && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 text-muted-foreground bg-accent border border-border dark:bg-accent/80 dark:border-border/50 leading-none"
|
||||
>
|
||||
{repo
|
||||
? getRepoKindLabel(repo)
|
||||
: translate('auto.components.sidebar.WorktreeCard.93aebe4529', 'Folder')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{typeof worktree.firstAgentMessageRenameError === 'string' &&
|
||||
worktree.firstAgentMessageRenameError.length > 0 &&
|
||||
!titleRenaming ? (
|
||||
|
|
@ -1155,14 +1195,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
)}
|
||||
|
||||
{isFolder ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 text-muted-foreground bg-accent border border-border dark:bg-accent/80 dark:border-border/50 leading-none"
|
||||
<span
|
||||
className="min-w-0 truncate font-mono text-[11px] leading-none text-muted-foreground"
|
||||
title={worktree.path}
|
||||
>
|
||||
{repo
|
||||
? getRepoKindLabel(repo)
|
||||
: translate('auto.components.sidebar.WorktreeCard.93aebe4529', 'Folder')}
|
||||
</Badge>
|
||||
{getDirectoryName(worktree.path)}
|
||||
</span>
|
||||
) : showBranch ? (
|
||||
<span className="min-w-0 text-[11px] text-muted-foreground truncate leading-none">
|
||||
{branch}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-st
|
|||
import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu'
|
||||
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
|
||||
type Props = {
|
||||
worktree: Worktree
|
||||
|
|
@ -217,6 +218,8 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
const projectGroups = useAppStore((s) => s.projectGroups)
|
||||
const createProjectGroup = useAppStore((s) => s.createProjectGroup)
|
||||
const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup)
|
||||
const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace)
|
||||
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
|
||||
const repo = useRepoById(worktree.repoId)
|
||||
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
|
@ -238,6 +241,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
const contextMenuOpenedAtRef = useRef<number | null>(null)
|
||||
const activeContextWorktrees = menuOpen ? contextWorktrees : effectiveSelectedWorktrees
|
||||
const isMultiContext = activeContextWorktrees.length > 1
|
||||
const workspaceScope = parseWorkspaceKey(worktree.id)
|
||||
const folderWorkspaceId =
|
||||
workspaceScope?.type === 'folder' ? workspaceScope.folderWorkspaceId : null
|
||||
const sleepableWorktrees = useMemo(
|
||||
() =>
|
||||
activeContextWorktrees.filter((item) =>
|
||||
|
|
@ -408,13 +414,33 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
restoreSidebarPosition()
|
||||
return
|
||||
}
|
||||
if (folderWorkspaceId) {
|
||||
void deleteFolderWorkspace(folderWorkspaceId).then((deleted) => {
|
||||
if (
|
||||
deleted &&
|
||||
useAppStore.getState().activeWorktreeId === folderWorkspaceKey(folderWorkspaceId)
|
||||
) {
|
||||
setActiveWorktree(null)
|
||||
}
|
||||
})
|
||||
restoreSidebarPosition()
|
||||
return
|
||||
}
|
||||
// Why delegate to runWorktreeDelete: keeps the delete-vs-project-removal
|
||||
// decision tree (and its rationale) in one place shared with command
|
||||
// surfaces and the memory popover's inline Delete action.
|
||||
runWorktreeDelete(worktree.id)
|
||||
restoreSidebarPosition()
|
||||
}, 50)
|
||||
}, [batchDeleteWorktrees, isMultiContext, setMenuOpenState, worktree.id])
|
||||
}, [
|
||||
batchDeleteWorktrees,
|
||||
deleteFolderWorkspace,
|
||||
folderWorkspaceId,
|
||||
isMultiContext,
|
||||
setActiveWorktree,
|
||||
setMenuOpenState,
|
||||
worktree.id
|
||||
])
|
||||
|
||||
const handleOpenParent = useCallback(() => {
|
||||
if (validParentWorktreeId) {
|
||||
|
|
@ -703,12 +729,17 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
? translate('auto.components.sidebar.WorktreeContextMenu.b42391d8bf', 'Deleting…')
|
||||
: isMultiContext
|
||||
? deleteLabel
|
||||
: removesProject
|
||||
: folderWorkspaceId
|
||||
? translate(
|
||||
'auto.components.sidebar.WorktreeContextMenu.f5ac91531d',
|
||||
'Remove Project from Orca'
|
||||
'auto.components.sidebar.WorktreeContextMenu.250de158fd',
|
||||
'Remove Workspace'
|
||||
)
|
||||
: translate('auto.components.sidebar.WorktreeContextMenu.f4475537d8', 'Delete')}
|
||||
: removesProject
|
||||
? translate(
|
||||
'auto.components.sidebar.WorktreeContextMenu.f5ac91531d',
|
||||
'Remove Project from Orca'
|
||||
)
|
||||
: translate('auto.components.sidebar.WorktreeContextMenu.f4475537d8', 'Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Eye,
|
||||
FolderInput,
|
||||
FolderPlus,
|
||||
FolderX,
|
||||
Plus,
|
||||
Shapes,
|
||||
SlidersHorizontal,
|
||||
|
|
@ -27,6 +28,8 @@ import {
|
|||
useWorktreeMap
|
||||
} from '@/store/selectors'
|
||||
import WorktreeCard from './WorktreeCard'
|
||||
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
|
||||
import { FolderWorkspaceComposerDialog } from './FolderWorkspaceComposerDialog'
|
||||
import { PendingWorktreeRow } from './PendingWorktreeRow'
|
||||
import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from './WorktreeCardAgents'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -45,6 +48,7 @@ import { cn } from '@/lib/utils'
|
|||
import type {
|
||||
Worktree,
|
||||
Repo,
|
||||
FolderWorkspace,
|
||||
ProjectGroup,
|
||||
ProjectOrderBy,
|
||||
WorktreeLineage,
|
||||
|
|
@ -110,6 +114,11 @@ import {
|
|||
type VirtualizedScrollAnchor
|
||||
} from '@/hooks/useVirtualizedScrollAnchor'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry'
|
||||
import {
|
||||
getFolderWorkspacePathStatusDescription,
|
||||
getFolderWorkspacePathStatusTitle
|
||||
} from '@/lib/folder-workspace-path-status'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import {
|
||||
SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT,
|
||||
|
|
@ -192,10 +201,21 @@ import { buildImportedWorktreesCardCandidates } from './imported-worktrees-card-
|
|||
import {
|
||||
WORKTREE_SECTION_HEADER_PADDING_LEFT,
|
||||
getProjectGroupHeaderPaddingLeft,
|
||||
getWorktreeCardContentIndent
|
||||
getWorktreeCardContentIndent,
|
||||
getWorktreeCardSurfaceInset
|
||||
} from './worktree-list-indentation'
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import {
|
||||
isConfirmedStaleFolderPathStatus,
|
||||
type FolderWorkspacePathStatus
|
||||
} from '../../../../shared/folder-workspace-path-status'
|
||||
import {
|
||||
getFolderWorkspaceRevealGroupKeys,
|
||||
getKnownSidebarWorktreeById,
|
||||
sidebarWorkspaceStillExists
|
||||
} from './worktree-list-folder-reveal'
|
||||
|
||||
export {
|
||||
getScrollTopToRevealBounds,
|
||||
|
|
@ -355,10 +375,6 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string {
|
|||
}
|
||||
|
||||
const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
|
||||
// Why: nested child worktree cards sit inside the parent card body. Preserve
|
||||
// the legacy lineage surface offset instead of using the full sidebar tree step.
|
||||
const NESTED_LINEAGE_CARD_INDENT = 14
|
||||
|
||||
type VirtualizedWorktreeViewportProps = {
|
||||
rows: Row[]
|
||||
activeWorktreeId: string | null
|
||||
|
|
@ -379,11 +395,13 @@ type VirtualizedWorktreeViewportProps = {
|
|||
handleRemoveProjectFromGroup: (repo: Repo) => void
|
||||
handleRenameProjectGroup: (groupId: string, currentName: string) => void
|
||||
handleDeleteProjectGroup: (groupId: string, groupName: string) => void
|
||||
handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void
|
||||
activeModal: string
|
||||
pendingRevealWorktree: PendingSidebarWorktreeReveal | null
|
||||
clearPendingRevealWorktreeId: () => void
|
||||
agentSendTargetWorktreeId: string | null
|
||||
worktrees: Worktree[]
|
||||
folderWorkspaces: readonly FolderWorkspace[]
|
||||
selectedWorktreeIds: ReadonlySet<string>
|
||||
selectedWorktrees: readonly Worktree[]
|
||||
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
|
||||
|
|
@ -438,6 +456,7 @@ type VirtualizedWorktreeViewportProps = {
|
|||
}
|
||||
|
||||
type WorktreeItemRow = Extract<Row, { type: 'item' }>
|
||||
type FolderWorkspaceItemRow = Extract<Row, { type: 'folder-workspace' }>
|
||||
|
||||
function formatSectionActivityLabel(count: number, label: string): string {
|
||||
return `${count} ${label}${count === 1 ? '' : 's'}`
|
||||
|
|
@ -465,6 +484,41 @@ function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element {
|
|||
)
|
||||
}
|
||||
|
||||
function FolderPathStatusIndicator({
|
||||
status
|
||||
}: {
|
||||
status: FolderWorkspacePathStatus | null | undefined
|
||||
}): React.JSX.Element | null {
|
||||
const title = getFolderWorkspacePathStatusTitle(status)
|
||||
if (!status || status.exists || !title) {
|
||||
return null
|
||||
}
|
||||
const destructive = isConfirmedStaleFolderPathStatus(status)
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-4 shrink-0 items-center justify-center rounded-[4px]',
|
||||
destructive ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
aria-label={title}
|
||||
>
|
||||
<FolderX className="size-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6} className="max-w-72">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{getFolderWorkspacePathStatusDescription(status)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
type WorktreeRowDragState = {
|
||||
draggingWorktreeId: string | null
|
||||
sourceGroupKey: string | null
|
||||
|
|
@ -594,6 +648,9 @@ export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | n
|
|||
if (worktreeId === null) {
|
||||
return false
|
||||
}
|
||||
if (row.type === 'folder-workspace') {
|
||||
return folderWorkspaceKey(row.folderWorkspace.id) === worktreeId
|
||||
}
|
||||
if (row.type === 'lineage-group') {
|
||||
return row.rows.some((item) => item.worktree.id === worktreeId)
|
||||
}
|
||||
|
|
@ -648,6 +705,9 @@ export function getRenderRowKey(row: RenderRow): string {
|
|||
if (row.type === 'pending-creation') {
|
||||
return `pending:${row.creationId}`
|
||||
}
|
||||
if (row.type === 'folder-workspace') {
|
||||
return `folder-workspace:${row.folderWorkspace.id}`
|
||||
}
|
||||
return `wt:${row.worktree.id}`
|
||||
}
|
||||
|
||||
|
|
@ -661,7 +721,11 @@ export function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] {
|
|||
groups.push({ key: current.key, worktreeIds: current.ids })
|
||||
continue
|
||||
}
|
||||
if (row.type === 'imported-worktrees-card' || row.type === 'pending-creation') {
|
||||
if (
|
||||
row.type === 'imported-worktrees-card' ||
|
||||
row.type === 'pending-creation' ||
|
||||
row.type === 'folder-workspace'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (!current) {
|
||||
|
|
@ -725,11 +789,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
handleRemoveProjectFromGroup,
|
||||
handleRenameProjectGroup,
|
||||
handleDeleteProjectGroup,
|
||||
handleCreateFolderWorkspace,
|
||||
activeModal,
|
||||
pendingRevealWorktree,
|
||||
clearPendingRevealWorktreeId,
|
||||
agentSendTargetWorktreeId,
|
||||
worktrees,
|
||||
folderWorkspaces,
|
||||
selectedWorktreeIds,
|
||||
selectedWorktrees,
|
||||
onSelectionGesture,
|
||||
|
|
@ -1000,6 +1066,82 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
[renderRows, activeWorktreeId]
|
||||
)
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
const {
|
||||
folderWorkspacePathStatuses,
|
||||
fetchFolderWorkspacePathStatus,
|
||||
getFolderWorkspacePathStatusCacheKey,
|
||||
getFreshFolderWorkspacePathStatus,
|
||||
activeRuntimeEnvironmentId
|
||||
} = useAppStore(
|
||||
useShallow((s) => ({
|
||||
folderWorkspacePathStatuses: s.folderWorkspacePathStatuses,
|
||||
fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus,
|
||||
getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey,
|
||||
getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus,
|
||||
activeRuntimeEnvironmentId: s.settings?.activeRuntimeEnvironmentId ?? null
|
||||
}))
|
||||
)
|
||||
const folderPathStatusRepoMembershipKey = useMemo(
|
||||
() =>
|
||||
allRepoIds
|
||||
.map((repoId) => {
|
||||
const repo = repoMap.get(repoId)
|
||||
return `${repoId}:${repo?.path ?? ''}:${repo?.projectGroupId ?? ''}:${repo?.connectionId ?? ''}`
|
||||
})
|
||||
.join('\0'),
|
||||
[allRepoIds, repoMap]
|
||||
)
|
||||
const folderPathStatusSshConnectionKey = useMemo(
|
||||
() =>
|
||||
[...sshConnectionStates.entries()]
|
||||
.map(([connectionId, state]) => `${connectionId}:${state.status}`)
|
||||
.sort()
|
||||
.join('\0'),
|
||||
[sshConnectionStates]
|
||||
)
|
||||
const folderPathStatusCacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick(
|
||||
folderWorkspacePathStatuses
|
||||
)
|
||||
useEffect(() => {
|
||||
const requests = new Map<string, Parameters<typeof fetchFolderWorkspacePathStatus>[0]>()
|
||||
for (const group of projectGroups) {
|
||||
if (group.parentPath) {
|
||||
const request = { scope: 'project-group' as const, projectGroupId: group.id }
|
||||
requests.set(getFolderWorkspacePathStatusCacheKey(request), request)
|
||||
}
|
||||
}
|
||||
for (const workspace of folderWorkspaces) {
|
||||
const request = { scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id }
|
||||
requests.set(getFolderWorkspacePathStatusCacheKey(request), request)
|
||||
}
|
||||
for (const request of requests.values()) {
|
||||
void fetchFolderWorkspacePathStatus(request, { force: true })
|
||||
}
|
||||
}, [
|
||||
activeRuntimeEnvironmentId,
|
||||
fetchFolderWorkspacePathStatus,
|
||||
folderPathStatusRepoMembershipKey,
|
||||
folderPathStatusSshConnectionKey,
|
||||
folderWorkspaces,
|
||||
getFolderWorkspacePathStatusCacheKey,
|
||||
projectGroups
|
||||
])
|
||||
const getCachedFolderWorkspacePathStatus = useCallback(
|
||||
(request: Parameters<typeof fetchFolderWorkspacePathStatus>[0]) => {
|
||||
const cacheKey = getFolderWorkspacePathStatusCacheKey(request)
|
||||
// Why: expired negative statuses should not keep disabling folder
|
||||
// workspaces while a fresh status request is in flight.
|
||||
void folderWorkspacePathStatuses[cacheKey]
|
||||
void folderPathStatusCacheExpiryTick
|
||||
return getFreshFolderWorkspacePathStatus(request)
|
||||
},
|
||||
[
|
||||
folderWorkspacePathStatuses,
|
||||
folderPathStatusCacheExpiryTick,
|
||||
getFolderWorkspacePathStatusCacheKey,
|
||||
getFreshFolderWorkspacePathStatus
|
||||
]
|
||||
)
|
||||
const renderRowsRef = useRef(renderRows)
|
||||
renderRowsRef.current = renderRows
|
||||
const getVirtualItemKey = useCallback(
|
||||
|
|
@ -1153,53 +1295,66 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
}
|
||||
|
||||
if (agentSendTargetWorktreeId !== pendingRevealWorktree.worktreeId) {
|
||||
const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId)
|
||||
if (targetWorktree && !targetWorktree.isPinned) {
|
||||
const seen = new Set<string>()
|
||||
let current: Worktree | undefined = targetWorktree
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
const lineage = worktreeLineageById[current.id]
|
||||
const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined
|
||||
if (
|
||||
!lineage ||
|
||||
!parent ||
|
||||
current.instanceId !== lineage.worktreeInstanceId ||
|
||||
parent.instanceId !== lineage.parentWorktreeInstanceId
|
||||
) {
|
||||
break
|
||||
}
|
||||
const lineageGroupKey = getLineageGroupKey(parent.id)
|
||||
if (collapsedGroups.has(lineageGroupKey)) {
|
||||
toggleGroup(lineageGroupKey)
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
if (targetWorktree?.isPinned) {
|
||||
// Why: pinned worktrees live in the dedicated "Pinned" section regardless
|
||||
// of their PR-status / project group. Only uncollapse the Pinned header
|
||||
// itself — expanding the underlying status group would be surprising since
|
||||
// the user intentionally collapsed it.
|
||||
if (collapsedGroups.has(PINNED_GROUP_KEY)) {
|
||||
toggleGroup(PINNED_GROUP_KEY)
|
||||
}
|
||||
} else if (targetWorktree) {
|
||||
const groupKeys = getGroupKeysForWorktree(
|
||||
groupBy,
|
||||
targetWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
workspaceStatuses,
|
||||
settings,
|
||||
projectGroups
|
||||
)
|
||||
for (const groupKey of groupKeys) {
|
||||
const folderGroupKeys = getFolderWorkspaceRevealGroupKeys(
|
||||
pendingRevealWorktree.worktreeId,
|
||||
folderWorkspaces,
|
||||
projectGroups
|
||||
)
|
||||
if (folderGroupKeys.length > 0) {
|
||||
for (const groupKey of folderGroupKeys) {
|
||||
if (collapsedGroups.has(groupKey)) {
|
||||
toggleGroup(groupKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId)
|
||||
if (targetWorktree && !targetWorktree.isPinned) {
|
||||
const seen = new Set<string>()
|
||||
let current: Worktree | undefined = targetWorktree
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
const lineage = worktreeLineageById[current.id]
|
||||
const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined
|
||||
if (
|
||||
!lineage ||
|
||||
!parent ||
|
||||
current.instanceId !== lineage.worktreeInstanceId ||
|
||||
parent.instanceId !== lineage.parentWorktreeInstanceId
|
||||
) {
|
||||
break
|
||||
}
|
||||
const lineageGroupKey = getLineageGroupKey(parent.id)
|
||||
if (collapsedGroups.has(lineageGroupKey)) {
|
||||
toggleGroup(lineageGroupKey)
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
if (targetWorktree?.isPinned) {
|
||||
// Why: pinned worktrees live in the dedicated "Pinned" section regardless
|
||||
// of their PR-status / project group. Only uncollapse the Pinned header
|
||||
// itself — expanding the underlying status group would be surprising since
|
||||
// the user intentionally collapsed it.
|
||||
if (collapsedGroups.has(PINNED_GROUP_KEY)) {
|
||||
toggleGroup(PINNED_GROUP_KEY)
|
||||
}
|
||||
} else if (targetWorktree) {
|
||||
const groupKeys = getGroupKeysForWorktree(
|
||||
groupBy,
|
||||
targetWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
workspaceStatuses,
|
||||
settings,
|
||||
projectGroups
|
||||
)
|
||||
for (const groupKey of groupKeys) {
|
||||
if (collapsedGroups.has(groupKey)) {
|
||||
toggleGroup(groupKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1208,8 +1363,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
const targetWorktreeStillExists = worktrees.some(
|
||||
(worktree) => worktree.id === pendingRevealWorktree.worktreeId
|
||||
const targetWorktreeStillExists = sidebarWorkspaceStillExists(
|
||||
pendingRevealWorktree.worktreeId,
|
||||
worktrees,
|
||||
folderWorkspaces
|
||||
)
|
||||
const targetIndex = renderRows.findIndex((row) =>
|
||||
renderRowContainsWorktree(row, pendingRevealWorktree.worktreeId)
|
||||
|
|
@ -1294,6 +1451,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
agentSendTargetWorktreeId,
|
||||
groupBy,
|
||||
worktrees,
|
||||
folderWorkspaces,
|
||||
repoMap,
|
||||
prCache,
|
||||
worktreeLineageById,
|
||||
|
|
@ -2778,6 +2936,20 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
: null
|
||||
})
|
||||
: null
|
||||
const projectGroupPathStatus =
|
||||
isProjectGroupHeader &&
|
||||
row.projectGroup &&
|
||||
'parentPath' in row.projectGroup &&
|
||||
row.projectGroup.parentPath
|
||||
? getCachedFolderWorkspacePathStatus({
|
||||
scope: 'project-group',
|
||||
projectGroupId: row.projectGroup.id
|
||||
})
|
||||
: null
|
||||
const folderWorkspaceCreateDisabled =
|
||||
projectGroupPathStatus?.exists === false &&
|
||||
(isConfirmedStaleFolderPathStatus(projectGroupPathStatus) ||
|
||||
projectGroupPathStatus.reason === 'ambiguous-connection')
|
||||
const projectGroupDepth = row.projectGroupDepth ?? 0
|
||||
// Why: non-project section headers like "All" are labels for the
|
||||
// flat list, so they should not reserve project hierarchy indent.
|
||||
|
|
@ -2906,6 +3078,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
{row.label}
|
||||
</div>
|
||||
<RepoForkIndicator upstream={row.repo?.upstream} />
|
||||
<FolderPathStatusIndicator status={projectGroupPathStatus} />
|
||||
<SectionMetricsBadge count={row.count} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2983,6 +3156,61 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{isProjectGroupHeader &&
|
||||
!row.repo &&
|
||||
row.projectGroup &&
|
||||
'parentPath' in row.projectGroup &&
|
||||
row.projectGroup.parentPath ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
data-repo-header-action=""
|
||||
className={cn(
|
||||
'size-5 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/70 hover:text-foreground focus:opacity-100 group-hover:opacity-100',
|
||||
folderWorkspaceCreateDisabled &&
|
||||
'cursor-not-allowed text-muted-foreground/60 hover:bg-transparent hover:text-muted-foreground/60'
|
||||
)}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorktreeList.bd37a57ac8',
|
||||
'Create workspace for {{value0}}',
|
||||
{ value0: row.label }
|
||||
)}
|
||||
aria-disabled={folderWorkspaceCreateDisabled}
|
||||
onKeyDown={stopRepoHeaderKeyboardToggle}
|
||||
onPointerDown={handleRepoHeaderActionPointerDown}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (folderWorkspaceCreateDisabled) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
row.projectGroup &&
|
||||
'parentPath' in row.projectGroup &&
|
||||
row.projectGroup.parentPath
|
||||
) {
|
||||
handleCreateFolderWorkspace(row.projectGroup)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{projectGroupPathStatus?.exists === false
|
||||
? getFolderWorkspacePathStatusDescription(projectGroupPathStatus)
|
||||
: translate(
|
||||
'auto.components.sidebar.WorktreeList.bd37a57ac8',
|
||||
'Create workspace for {{value0}}',
|
||||
{ value0: row.label }
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{row.repo && groupBy === 'repo' ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<Tooltip>
|
||||
|
|
@ -3220,7 +3448,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
// still owns the project/group inset inside each card surface.
|
||||
const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth
|
||||
const nestedCardPaddingLeft = nested
|
||||
? Math.max(0, itemRow.depth) * NESTED_LINEAGE_CARD_INDENT
|
||||
? getWorktreeCardSurfaceInset({
|
||||
isGrouped: true,
|
||||
groupDepth: itemRow.depth
|
||||
})
|
||||
: 0
|
||||
const inheritedCardContentIndent = getWorktreeCardContentIndent({
|
||||
isGrouped: groupBy !== 'none',
|
||||
|
|
@ -3234,7 +3465,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
groupDepth: itemRow.groupDepth,
|
||||
lineageDepth: paddingDepth
|
||||
})
|
||||
const cardContentIndent = nested ? inheritedCardContentIndent : paddingLeft
|
||||
const surfaceInset = nested
|
||||
? nestedCardPaddingLeft
|
||||
: getWorktreeCardSurfaceInset({
|
||||
isGrouped: groupBy !== 'none',
|
||||
groupDepth: itemRow.groupDepth
|
||||
})
|
||||
const cardContentIndent = Math.max(
|
||||
0,
|
||||
(nested ? inheritedCardContentIndent : paddingLeft) - surfaceInset
|
||||
)
|
||||
const worktreeDragGroupKey = groupKeyByWorktreeId.get(itemRow.worktree.id)
|
||||
const worktreeDragGroupIndex = groupIndexByWorktreeId.get(itemRow.worktree.id)
|
||||
const revealHighlightTone =
|
||||
|
|
@ -3271,8 +3511,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
nested ? undefined : handleWorktreeRowPointerDown(event, itemRow.worktree.id)
|
||||
}
|
||||
style={{
|
||||
paddingLeft:
|
||||
nested && nestedCardPaddingLeft > 0 ? `${nestedCardPaddingLeft}px` : undefined
|
||||
paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined
|
||||
}}
|
||||
>
|
||||
<WorktreeCard
|
||||
|
|
@ -3435,6 +3674,73 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
)
|
||||
}
|
||||
|
||||
if (row.type === 'folder-workspace') {
|
||||
const folderWorkspaceRow = row as FolderWorkspaceItemRow
|
||||
const folderWorktree = folderWorkspaceToWorktree(folderWorkspaceRow.folderWorkspace)
|
||||
const folderWorkspacePathStatus = getCachedFolderWorkspacePathStatus({
|
||||
scope: 'folder-workspace',
|
||||
folderWorkspaceId: folderWorkspaceRow.folderWorkspace.id
|
||||
})
|
||||
const folderWorkspaceActivationDisabled =
|
||||
folderWorkspacePathStatus?.exists === false &&
|
||||
(isConfirmedStaleFolderPathStatus(folderWorkspacePathStatus) ||
|
||||
folderWorkspacePathStatus.reason === 'ambiguous-connection')
|
||||
const contentIndent = getWorktreeCardContentIndent({
|
||||
isGrouped: groupBy !== 'none',
|
||||
groupDepth: folderWorkspaceRow.groupDepth,
|
||||
lineageDepth: folderWorkspaceRow.depth
|
||||
})
|
||||
// Why: folder workspace surfaces should step inward with their
|
||||
// project-group nesting, matching lineage child card surfaces
|
||||
// instead of spanning from the sidebar edge at every depth.
|
||||
const surfaceInset = getWorktreeCardSurfaceInset({
|
||||
isGrouped: groupBy !== 'none',
|
||||
groupDepth: folderWorkspaceRow.groupDepth
|
||||
})
|
||||
const insetContentIndent = Math.max(0, contentIndent - surfaceInset)
|
||||
return (
|
||||
<div
|
||||
key={vItem.key}
|
||||
id={getWorktreeOptionId(folderWorktree.id)}
|
||||
role="option"
|
||||
aria-selected={selectedWorktreeIds.has(folderWorktree.id)}
|
||||
aria-current={activeWorktreeId === folderWorktree.id ? 'page' : undefined}
|
||||
data-worktree-virtual-row
|
||||
data-worktree-virtual-row-key={String(vItem.key)}
|
||||
data-worktree-virtual-row-start={vItem.start}
|
||||
data-index={vItem.index}
|
||||
ref={measureVirtualRowElement}
|
||||
className="absolute left-0 right-0 top-0"
|
||||
style={{ transform: getVirtualRowTransform(vItem.start) }}
|
||||
onClickCapture={handleWorktreeRowClickCapture}
|
||||
onPointerDown={(event) => handleWorktreeRowPointerDown(event, folderWorktree.id)}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={surfaceInset > 0 ? { paddingLeft: surfaceInset } : undefined}
|
||||
>
|
||||
<WorktreeCard
|
||||
worktree={folderWorktree}
|
||||
repo={undefined}
|
||||
isActive={activeWorktreeId === folderWorktree.id}
|
||||
isCurrentWorktree={currentWorktreeId === folderWorktree.id}
|
||||
contentIndent={insetContentIndent}
|
||||
flushSurface
|
||||
nativeDragEnabled={false}
|
||||
onImmediateActivate={
|
||||
folderWorkspaceActivationDisabled ? undefined : onImmediateWorktreeActivate
|
||||
}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={onContextMenuSelect}
|
||||
/>
|
||||
<div className="pointer-events-auto absolute right-3 top-1.5">
|
||||
<FolderPathStatusIndicator status={folderWorkspacePathStatus} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const itemWorkspaceStatus =
|
||||
groupBy === 'workspace-status'
|
||||
? getWorkspaceStatus(row.worktree, workspaceStatuses)
|
||||
|
|
@ -3873,6 +4179,7 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
// header order from the sorted visible worktree stream instead.
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS)
|
||||
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
|
||||
const effectiveCollapsedGroups = useMemo(() => {
|
||||
if (!agentSendTargetWorktreeId) {
|
||||
return collapsedGroups
|
||||
|
|
@ -3994,7 +4301,8 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
projectGroups,
|
||||
placeholderRepoIds,
|
||||
importedWorktreesByRepo,
|
||||
pendingCreations
|
||||
pendingCreations,
|
||||
folderWorkspaces
|
||||
),
|
||||
[
|
||||
groupBy,
|
||||
|
|
@ -4011,7 +4319,8 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
projectGroups,
|
||||
placeholderRepoIds,
|
||||
importedWorktreesByRepo,
|
||||
pendingCreations
|
||||
pendingCreations,
|
||||
folderWorkspaces
|
||||
]
|
||||
)
|
||||
// Why: status headers change during wake (inactive -> active). Key only on
|
||||
|
|
@ -4025,9 +4334,15 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
// positions when grouping is active.
|
||||
const renderedWorktrees = useMemo(
|
||||
() =>
|
||||
rows
|
||||
.filter((r): r is Extract<Row, { type: 'item' }> => r.type === 'item')
|
||||
.map((r) => r.worktree),
|
||||
rows.flatMap((row) => {
|
||||
if (row.type === 'item') {
|
||||
return [row.worktree]
|
||||
}
|
||||
if (row.type === 'folder-workspace') {
|
||||
return [folderWorkspaceToWorktree(row.folderWorkspace)]
|
||||
}
|
||||
return []
|
||||
}),
|
||||
[rows]
|
||||
)
|
||||
const renderedWorktreeIds = useMemo(
|
||||
|
|
@ -4214,6 +4529,9 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
useState<ProjectGroupNameDialogState | null>(null)
|
||||
const [projectGroupDeleteDialog, setProjectGroupDeleteDialog] =
|
||||
useState<ProjectGroupDeleteDialogState | null>(null)
|
||||
const [folderWorkspaceCreateGroup, setFolderWorkspaceCreateGroup] = useState<ProjectGroup | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const handleCreateGroupFromRepo = useCallback((repo: Repo) => {
|
||||
setProjectGroupNameDialog({ type: 'create-from-repo', repo })
|
||||
|
|
@ -4338,6 +4656,13 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
projectGroupDeleteDialog
|
||||
])
|
||||
|
||||
const handleCreateFolderWorkspace = useCallback((projectGroup: ProjectGroup) => {
|
||||
if (!projectGroup.parentPath) {
|
||||
return
|
||||
}
|
||||
setFolderWorkspaceCreateGroup(projectGroup)
|
||||
}, [])
|
||||
|
||||
const moveWorktreeToStatus = useCallback(
|
||||
(worktreeId: string, status: WorkspaceStatus) => {
|
||||
const current = worktreeMap.get(worktreeId)
|
||||
|
|
@ -4550,11 +4875,15 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
const activeWorktree = worktreeMap.get(activeWorktreeId)
|
||||
const activeWorktree = getKnownSidebarWorktreeById(
|
||||
activeWorktreeId,
|
||||
worktreeMap,
|
||||
folderWorkspaces
|
||||
)
|
||||
if (!activeWorktree || activeWorktree.isArchived) {
|
||||
return
|
||||
}
|
||||
if (!worktrees.some((worktree) => worktree.id === activeWorktreeId)) {
|
||||
if (!renderedWorktreeIds.includes(activeWorktreeId)) {
|
||||
// Why: the toolbar action promises to reveal the current workspace; when
|
||||
// sidebar filters hide it, relax those filters before queuing the reveal.
|
||||
clearFilters()
|
||||
|
|
@ -4565,7 +4894,14 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
beginRename: detail?.beginRename === true
|
||||
})
|
||||
},
|
||||
[activeWorktreeId, clearFilters, revealWorktreeInSidebar, worktreeMap, worktrees]
|
||||
[
|
||||
activeWorktreeId,
|
||||
clearFilters,
|
||||
folderWorkspaces,
|
||||
renderedWorktreeIds,
|
||||
revealWorktreeInSidebar,
|
||||
worktreeMap
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -4668,6 +5004,15 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
}}
|
||||
onConfirm={handleConfirmDeleteProjectGroup}
|
||||
/>
|
||||
<FolderWorkspaceComposerDialog
|
||||
open={folderWorkspaceCreateGroup !== null}
|
||||
projectGroup={folderWorkspaceCreateGroup}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setFolderWorkspaceCreateGroup(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<VirtualizedWorktreeViewport
|
||||
key={viewportResetKey}
|
||||
rows={rows}
|
||||
|
|
@ -4689,11 +5034,13 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
handleRemoveProjectFromGroup={handleRemoveProjectFromGroup}
|
||||
handleRenameProjectGroup={handleRenameProjectGroup}
|
||||
handleDeleteProjectGroup={handleDeleteProjectGroup}
|
||||
handleCreateFolderWorkspace={handleCreateFolderWorkspace}
|
||||
activeModal={activeModal}
|
||||
pendingRevealWorktree={pendingRevealWorktree}
|
||||
clearPendingRevealWorktreeId={clearPendingRevealWorktreeId}
|
||||
agentSendTargetWorktreeId={agentSendTargetWorktreeId}
|
||||
worktrees={worktrees}
|
||||
folderWorkspaces={folderWorkspaces}
|
||||
selectedWorktreeIds={selectedWorktreeIds}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
onSelectionGesture={updateSelectionForGesture}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item'
|
||||
import {
|
||||
getLinkedWorkItemProvider,
|
||||
getLinkedWorkItemWorkspaceName,
|
||||
type LinkedWorkItemSummary
|
||||
} from '@/lib/new-workspace'
|
||||
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
|
||||
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import type {
|
||||
FolderWorkspace,
|
||||
GitHubWorkItem,
|
||||
GitLabWorkItem,
|
||||
LinearIssue,
|
||||
ProjectGroup,
|
||||
Repo
|
||||
} from '../../../../shared/types'
|
||||
import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField'
|
||||
|
||||
const EMPTY_REPOS: Repo[] = []
|
||||
|
||||
export function getFolderSourceRepos(
|
||||
repos: readonly Repo[],
|
||||
projectGroups: readonly ProjectGroup[],
|
||||
projectGroup: ProjectGroup | null
|
||||
): Repo[] {
|
||||
if (!projectGroup?.parentPath) {
|
||||
return EMPTY_REPOS
|
||||
}
|
||||
const folderPath = projectGroup.parentPath
|
||||
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroup.id)
|
||||
return repos.filter(
|
||||
(repo) =>
|
||||
isGitRepoKind(repo) &&
|
||||
((typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
|
||||
isPathInsideOrEqual(folderPath, repo.path))
|
||||
)
|
||||
}
|
||||
|
||||
export function toFolderWorkspaceLinkedTask(
|
||||
item: LinkedWorkItemSummary | null
|
||||
): FolderWorkspace['linkedTask'] {
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
const provider = getLinkedWorkItemProvider(item)
|
||||
return {
|
||||
provider,
|
||||
type: item.type,
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
...(item.linearIdentifier ? { linearIdentifier: item.linearIdentifier } : {}),
|
||||
...(item.jiraIdentifier ? { jiraIdentifier: item.jiraIdentifier } : {}),
|
||||
...(item.repoId ? { repoId: item.repoId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function getSmartNameSelection(
|
||||
linkedWorkItem: LinkedWorkItemSummary | null
|
||||
): SmartWorkspaceNameSelection | null {
|
||||
if (!linkedWorkItem) {
|
||||
return null
|
||||
}
|
||||
const provider = getLinkedWorkItemProvider(linkedWorkItem)
|
||||
const kind: SmartWorkspaceNameSelection['kind'] =
|
||||
provider === 'linear'
|
||||
? 'linear'
|
||||
: provider === 'jira'
|
||||
? 'jira'
|
||||
: provider === 'gitlab'
|
||||
? linkedWorkItem.type === 'mr'
|
||||
? 'gitlab-mr'
|
||||
: 'gitlab-issue'
|
||||
: linkedWorkItem.type === 'pr'
|
||||
? 'github-pr'
|
||||
: 'github-issue'
|
||||
return {
|
||||
kind,
|
||||
label:
|
||||
provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0
|
||||
? linkedWorkItem.title
|
||||
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
|
||||
url: linkedWorkItem.url
|
||||
}
|
||||
}
|
||||
|
||||
export function getLinkedItemDisplayName(item: LinkedWorkItemSummary): string | null {
|
||||
return getLinkedWorkItemWorkspaceName(item)?.displayName ?? (item.title.trim() || null)
|
||||
}
|
||||
|
||||
export function toGitHubLinkedWorkItem(item: GitHubWorkItem): LinkedWorkItemSummary {
|
||||
return {
|
||||
type: item.type,
|
||||
provider: 'github',
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
repoId: item.repoId
|
||||
}
|
||||
}
|
||||
|
||||
export function toGitLabLinkedWorkItem(item: GitLabWorkItem): LinkedWorkItemSummary {
|
||||
return {
|
||||
type: item.type,
|
||||
provider: 'gitlab',
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
repoId: item.repoId
|
||||
}
|
||||
}
|
||||
|
||||
export function toLinearLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary {
|
||||
return buildLinearIssueLinkedWorkItem(issue)
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProjectGroup } from '../../../../shared/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useFolderWorkspaceComposerPathStatus } from './folder-workspace-composer-path-status'
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
|
||||
const projectGroup: ProjectGroup = {
|
||||
id: 'group-1',
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
connectionId: null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const projectGroupRequestSnapshot = '/workspace/platform\0group-1\0\0\0'
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
function HookProbe(): null {
|
||||
const result = useFolderWorkspaceComposerPathStatus(projectGroup, true)
|
||||
;(
|
||||
globalThis as { __folderWorkspaceComposerPathStatusResult?: typeof result }
|
||||
).__folderWorkspaceComposerPathStatusResult = result
|
||||
return null
|
||||
}
|
||||
|
||||
describe('useFolderWorkspaceComposerPathStatus', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
act(() => {
|
||||
root?.unmount()
|
||||
})
|
||||
root = null
|
||||
container?.remove()
|
||||
container = null
|
||||
delete (globalThis as { __folderWorkspaceComposerPathStatusResult?: unknown })
|
||||
.__folderWorkspaceComposerPathStatusResult
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
it('does not block creation with an expired negative path status', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(20_000)
|
||||
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
|
||||
const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request)
|
||||
const fetchFolderWorkspacePathStatus = vi.fn()
|
||||
useAppStore.setState({
|
||||
projectGroups: [projectGroup],
|
||||
fetchFolderWorkspacePathStatus,
|
||||
folderWorkspacePathStatuses: {
|
||||
[cacheKey]: {
|
||||
status: {
|
||||
path: '/workspace/platform',
|
||||
exists: false,
|
||||
reason: 'missing'
|
||||
},
|
||||
checkedAt: 0,
|
||||
requestSnapshot: projectGroupRequestSnapshot
|
||||
}
|
||||
}
|
||||
})
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root?.render(<HookProbe />)
|
||||
})
|
||||
|
||||
expect(
|
||||
(
|
||||
globalThis as {
|
||||
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
|
||||
}
|
||||
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
|
||||
).toBe(false)
|
||||
expect(
|
||||
(
|
||||
globalThis as {
|
||||
__folderWorkspaceComposerPathStatusResult?: { pathStatusProjectError: string | null }
|
||||
}
|
||||
).__folderWorkspaceComposerPathStatusResult?.pathStatusProjectError
|
||||
).toBeNull()
|
||||
expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, { force: true })
|
||||
})
|
||||
|
||||
it('does not block creation for an unavailable path status', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(20_000)
|
||||
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
|
||||
const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request)
|
||||
useAppStore.setState({
|
||||
projectGroups: [projectGroup],
|
||||
fetchFolderWorkspacePathStatus: vi.fn(),
|
||||
folderWorkspacePathStatuses: {
|
||||
[cacheKey]: {
|
||||
status: {
|
||||
path: '/workspace/platform',
|
||||
exists: false,
|
||||
reason: 'unavailable'
|
||||
},
|
||||
checkedAt: 20_000,
|
||||
requestSnapshot: projectGroupRequestSnapshot
|
||||
}
|
||||
}
|
||||
})
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root?.render(<HookProbe />)
|
||||
})
|
||||
|
||||
expect(
|
||||
(
|
||||
globalThis as {
|
||||
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
|
||||
}
|
||||
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rerenders when a cached blocking path status expires', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(20_000)
|
||||
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
|
||||
const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request)
|
||||
useAppStore.setState({
|
||||
projectGroups: [projectGroup],
|
||||
fetchFolderWorkspacePathStatus: vi.fn(),
|
||||
folderWorkspacePathStatuses: {
|
||||
[cacheKey]: {
|
||||
status: {
|
||||
path: '/workspace/platform',
|
||||
exists: false,
|
||||
reason: 'missing'
|
||||
},
|
||||
checkedAt: 20_000,
|
||||
requestSnapshot: projectGroupRequestSnapshot
|
||||
}
|
||||
}
|
||||
})
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root?.render(<HookProbe />)
|
||||
})
|
||||
expect(
|
||||
(
|
||||
globalThis as {
|
||||
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
|
||||
}
|
||||
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
|
||||
).toBe(true)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_001)
|
||||
})
|
||||
|
||||
expect(
|
||||
(
|
||||
globalThis as {
|
||||
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
|
||||
}
|
||||
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { useEffect, useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry'
|
||||
import {
|
||||
getFolderWorkspacePathStatusDescription,
|
||||
getFolderWorkspacePathStatusTitle
|
||||
} from '@/lib/folder-workspace-path-status'
|
||||
import { isConfirmedStaleFolderPathStatus } from '../../../../shared/folder-workspace-path-status'
|
||||
import type { ProjectGroup } from '../../../../shared/types'
|
||||
|
||||
export function useFolderWorkspaceComposerPathStatus(
|
||||
projectGroup: ProjectGroup | null,
|
||||
open: boolean
|
||||
): {
|
||||
pathStatusBlocksCreate: boolean
|
||||
pathStatusProjectError: string | null
|
||||
} {
|
||||
const {
|
||||
folderWorkspacePathStatuses,
|
||||
fetchFolderWorkspacePathStatus,
|
||||
getFolderWorkspacePathStatusCacheKey,
|
||||
getFreshFolderWorkspacePathStatus
|
||||
} = useAppStore(
|
||||
useShallow((s) => ({
|
||||
folderWorkspacePathStatuses: s.folderWorkspacePathStatuses,
|
||||
fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus,
|
||||
getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey,
|
||||
getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus
|
||||
}))
|
||||
)
|
||||
const pathStatusRequest = useMemo(
|
||||
() =>
|
||||
projectGroup ? { scope: 'project-group' as const, projectGroupId: projectGroup.id } : null,
|
||||
[projectGroup]
|
||||
)
|
||||
const cacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick(folderWorkspacePathStatuses)
|
||||
const pathStatus = useMemo(() => {
|
||||
if (!pathStatusRequest) {
|
||||
return null
|
||||
}
|
||||
const cacheKey = getFolderWorkspacePathStatusCacheKey(pathStatusRequest)
|
||||
// Why: subscribe to cache writes, but only let the TTL-aware accessor decide
|
||||
// whether a cached negative status is still authoritative.
|
||||
void folderWorkspacePathStatuses[cacheKey]
|
||||
void cacheExpiryTick
|
||||
return getFreshFolderWorkspacePathStatus(pathStatusRequest)
|
||||
}, [
|
||||
folderWorkspacePathStatuses,
|
||||
cacheExpiryTick,
|
||||
getFolderWorkspacePathStatusCacheKey,
|
||||
getFreshFolderWorkspacePathStatus,
|
||||
pathStatusRequest
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !pathStatusRequest) {
|
||||
return
|
||||
}
|
||||
void fetchFolderWorkspacePathStatus(pathStatusRequest, { force: true })
|
||||
}, [fetchFolderWorkspacePathStatus, open, pathStatusRequest])
|
||||
|
||||
const pathStatusBlocksCreate =
|
||||
pathStatus?.exists === false &&
|
||||
(isConfirmedStaleFolderPathStatus(pathStatus) || pathStatus.reason === 'ambiguous-connection')
|
||||
const title = pathStatus?.exists === false ? getFolderWorkspacePathStatusTitle(pathStatus) : null
|
||||
const pathStatusProjectError =
|
||||
title && pathStatus ? `${title}. ${getFolderWorkspacePathStatusDescription(pathStatus)}` : null
|
||||
|
||||
return { pathStatusBlocksCreate, pathStatusProjectError }
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import {
|
||||
CLIENT_PLATFORM,
|
||||
buildAgentPromptWithContext,
|
||||
type LinkedWorkItemSummary
|
||||
} from '@/lib/new-workspace'
|
||||
import { getLinkedWorkItemPromptContext } from '@/lib/linked-work-item-context'
|
||||
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { tuiAgentToAgentKind } from '@/lib/telemetry'
|
||||
import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation'
|
||||
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
|
||||
import type { FolderWorkspace, ProjectGroup, TuiAgent } from '../../../../shared/types'
|
||||
import {
|
||||
getLinkedItemDisplayName,
|
||||
toFolderWorkspaceLinkedTask
|
||||
} from './folder-workspace-composer-helpers'
|
||||
|
||||
type FolderWorkspaceCreateInput = {
|
||||
projectGroupId: string
|
||||
name: string
|
||||
linkedTask: FolderWorkspace['linkedTask']
|
||||
createdWithAgent?: TuiAgent
|
||||
pendingFirstAgentMessageRename?: boolean
|
||||
}
|
||||
|
||||
type SubmitFolderWorkspaceCreateParams = {
|
||||
projectGroup: ProjectGroup
|
||||
name: string
|
||||
lastAutoName: string
|
||||
linkedWorkItem: LinkedWorkItemSummary | null
|
||||
note: string
|
||||
quickAgent: TuiAgent | null
|
||||
autoRenameBranchFromWork: boolean | undefined
|
||||
agentCmdOverrides: Record<string, string> | undefined
|
||||
createFolderWorkspace: (input: FolderWorkspaceCreateInput) => Promise<FolderWorkspace | null>
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export async function submitFolderWorkspaceCreate({
|
||||
projectGroup,
|
||||
name,
|
||||
lastAutoName,
|
||||
linkedWorkItem,
|
||||
note,
|
||||
quickAgent,
|
||||
autoRenameBranchFromWork,
|
||||
agentCmdOverrides,
|
||||
createFolderWorkspace,
|
||||
onOpenChange
|
||||
}: SubmitFolderWorkspaceCreateParams): Promise<void> {
|
||||
const linkedName = linkedWorkItem ? getLinkedItemDisplayName(linkedWorkItem) : null
|
||||
const nameIsAutoManaged = !name.trim() || name === lastAutoName || isWorkItemLookupText(name)
|
||||
const workspaceName =
|
||||
nameIsAutoManaged && linkedName
|
||||
? linkedName
|
||||
: name.trim() || linkedName || `${projectGroup.name} workspace`
|
||||
const pendingFirstAgentMessageRename =
|
||||
autoRenameBranchFromWork === true && !name.trim() && !linkedWorkItem && Boolean(quickAgent)
|
||||
|
||||
const workspace = await createFolderWorkspace({
|
||||
projectGroupId: projectGroup.id,
|
||||
name: workspaceName,
|
||||
linkedTask: toFolderWorkspaceLinkedTask(linkedWorkItem),
|
||||
...(quickAgent ? { createdWithAgent: quickAgent } : {}),
|
||||
...(pendingFirstAgentMessageRename ? { pendingFirstAgentMessageRename: true } : {})
|
||||
})
|
||||
if (!workspace) {
|
||||
return
|
||||
}
|
||||
|
||||
const linkedPromptContext = getLinkedWorkItemPromptContext(linkedWorkItem)
|
||||
const startupPrompt = buildAgentPromptWithContext(
|
||||
note,
|
||||
[],
|
||||
linkedPromptContext.linkedUrls,
|
||||
linkedPromptContext.linkedContextBlocks
|
||||
)
|
||||
const startupPlan = quickAgent
|
||||
? buildAgentStartupPlan({
|
||||
agent: quickAgent,
|
||||
prompt: startupPrompt,
|
||||
cmdOverrides: agentCmdOverrides ?? {},
|
||||
platform: CLIENT_PLATFORM,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
: null
|
||||
const startup =
|
||||
quickAgent && startupPlan
|
||||
? {
|
||||
command: startupPlan.launchCommand,
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
telemetry: {
|
||||
agent_kind: tuiAgentToAgentKind(quickAgent),
|
||||
launch_source: 'sidebar' as const,
|
||||
request_kind: 'new' as const
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
activateAndRevealFolderWorkspace(workspace.id, startup ? { startup } : undefined)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ type WorktreeDragUnitRow =
|
|||
| { type: 'item'; worktree: { id: string }; depth: number }
|
||||
| { type: 'imported-worktrees-card' }
|
||||
| { type: 'pending-creation' }
|
||||
| { type: 'folder-workspace' }
|
||||
|
||||
export function getWorktreeDragUnitGroups(
|
||||
rows: readonly WorktreeDragUnitRow[]
|
||||
|
|
@ -27,7 +28,11 @@ export function getWorktreeDragUnitGroups(
|
|||
})
|
||||
continue
|
||||
}
|
||||
if (row.type === 'imported-worktrees-card' || row.type === 'pending-creation') {
|
||||
if (
|
||||
row.type === 'imported-worktrees-card' ||
|
||||
row.type === 'pending-creation' ||
|
||||
row.type === 'folder-workspace'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (!current) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { FolderWorkspace, ProjectGroup, Worktree } from '../../../../shared/types'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import {
|
||||
getFolderWorkspaceRevealGroupKeys,
|
||||
getKnownSidebarWorktreeById,
|
||||
sidebarWorkspaceStillExists
|
||||
} from './worktree-list-folder-reveal'
|
||||
import { getProjectGroupHeaderKey } from './worktree-list-groups'
|
||||
|
||||
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
|
||||
return {
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: 'group-child',
|
||||
name: 'Refund workflow',
|
||||
folderPath: '/workspace/platform',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeProjectGroup(overrides: Partial<ProjectGroup>): ProjectGroup {
|
||||
return {
|
||||
id: 'group-1',
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 1,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorktree(id: string): Worktree {
|
||||
return {
|
||||
id,
|
||||
repoId: 'repo-1',
|
||||
path: `/workspace/repo/${id}`,
|
||||
displayName: id,
|
||||
branch: id,
|
||||
head: 'abc123',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('worktree list folder reveal', () => {
|
||||
it('resolves synthetic folder workspace ids as known sidebar worktrees', () => {
|
||||
const folderWorkspace = makeFolderWorkspace()
|
||||
const folderWorktree = getKnownSidebarWorktreeById(
|
||||
folderWorkspaceKey(folderWorkspace.id),
|
||||
new Map(),
|
||||
[folderWorkspace]
|
||||
)
|
||||
|
||||
expect(folderWorktree).toMatchObject({
|
||||
id: folderWorkspaceKey(folderWorkspace.id),
|
||||
displayName: folderWorkspace.name,
|
||||
path: folderWorkspace.folderPath
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps pending reveals alive for folder workspaces missing from raw git worktrees', () => {
|
||||
const folderWorkspace = makeFolderWorkspace()
|
||||
const gitWorktree = makeWorktree('git-worktree-1')
|
||||
|
||||
expect(
|
||||
sidebarWorkspaceStillExists(
|
||||
folderWorkspaceKey(folderWorkspace.id),
|
||||
[gitWorktree],
|
||||
[folderWorkspace]
|
||||
)
|
||||
).toBe(true)
|
||||
expect(sidebarWorkspaceStillExists('missing-worktree', [gitWorktree], [folderWorkspace])).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('returns project group keys from root to nested folder workspace owner', () => {
|
||||
const root = makeProjectGroup({ id: 'group-root', name: 'Company' })
|
||||
const child = makeProjectGroup({
|
||||
id: 'group-child',
|
||||
name: 'Platform',
|
||||
parentGroupId: root.id
|
||||
})
|
||||
const folderWorkspace = makeFolderWorkspace({ projectGroupId: child.id })
|
||||
|
||||
expect(
|
||||
getFolderWorkspaceRevealGroupKeys(
|
||||
folderWorkspaceKey(folderWorkspace.id),
|
||||
[folderWorkspace],
|
||||
[child, root]
|
||||
)
|
||||
).toEqual([getProjectGroupHeaderKey(root.id), getProjectGroupHeaderKey(child.id)])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import type { FolderWorkspace, ProjectGroup, Worktree } from '../../../../shared/types'
|
||||
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { getProjectGroupHeaderKey } from './worktree-list-groups'
|
||||
|
||||
function findFolderWorkspaceByKey(
|
||||
worktreeId: string,
|
||||
folderWorkspaces: readonly FolderWorkspace[]
|
||||
): FolderWorkspace | null {
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
if (scope?.type !== 'folder') {
|
||||
return null
|
||||
}
|
||||
return folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId) ?? null
|
||||
}
|
||||
|
||||
export function getKnownSidebarWorktreeById(
|
||||
worktreeId: string,
|
||||
worktreeMap: ReadonlyMap<string, Worktree>,
|
||||
folderWorkspaces: readonly FolderWorkspace[]
|
||||
): Worktree | null {
|
||||
const worktree = worktreeMap.get(worktreeId)
|
||||
if (worktree) {
|
||||
return worktree
|
||||
}
|
||||
const folderWorkspace = findFolderWorkspaceByKey(worktreeId, folderWorkspaces)
|
||||
return folderWorkspace ? folderWorkspaceToWorktree(folderWorkspace) : null
|
||||
}
|
||||
|
||||
export function sidebarWorkspaceStillExists(
|
||||
worktreeId: string,
|
||||
worktrees: readonly Worktree[],
|
||||
folderWorkspaces: readonly FolderWorkspace[]
|
||||
): boolean {
|
||||
if (worktrees.some((worktree) => worktree.id === worktreeId)) {
|
||||
return true
|
||||
}
|
||||
return findFolderWorkspaceByKey(worktreeId, folderWorkspaces) !== null
|
||||
}
|
||||
|
||||
export function getFolderWorkspaceRevealGroupKeys(
|
||||
worktreeId: string,
|
||||
folderWorkspaces: readonly FolderWorkspace[],
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
): string[] {
|
||||
const folderWorkspace = findFolderWorkspaceByKey(worktreeId, folderWorkspaces)
|
||||
if (!folderWorkspace) {
|
||||
return []
|
||||
}
|
||||
|
||||
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
|
||||
const keys: string[] = []
|
||||
const seen = new Set<string>()
|
||||
let groupId: string | null = folderWorkspace.projectGroupId
|
||||
while (groupId && !seen.has(groupId)) {
|
||||
seen.add(groupId)
|
||||
const group = groupsById.get(groupId)
|
||||
if (!group) {
|
||||
break
|
||||
}
|
||||
keys.unshift(getProjectGroupHeaderKey(group.id))
|
||||
groupId = group.parentGroupId
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from './worktree-list-groups'
|
||||
import type {
|
||||
DetectedWorktree,
|
||||
FolderWorkspace,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
Worktree,
|
||||
|
|
@ -1314,6 +1315,209 @@ describe('project groups', () => {
|
|||
expect(rows[0]).toMatchObject({ count: 1 })
|
||||
})
|
||||
|
||||
it('renders folder workspaces under their owning folder-backed Project Group', () => {
|
||||
const group: ProjectGroup = {
|
||||
id: 'group-root',
|
||||
name: 'Platform',
|
||||
parentPath: '/monorepo',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const folderWorkspace: FolderWorkspace = {
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: group.id,
|
||||
name: 'Refund fix',
|
||||
folderPath: '/monorepo',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 10,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
const rows = buildRows(
|
||||
'repo',
|
||||
[],
|
||||
new Map(),
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
[group],
|
||||
new Set(),
|
||||
new Map(),
|
||||
[],
|
||||
[folderWorkspace]
|
||||
)
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
{
|
||||
type: 'header',
|
||||
key: 'project-group:group-root',
|
||||
count: 1
|
||||
},
|
||||
{
|
||||
type: 'folder-workspace',
|
||||
folderWorkspace: { id: 'folder-workspace-1' },
|
||||
projectGroup: { id: 'group-root' },
|
||||
groupDepth: 1
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves nested Project Group depth for folder workspace rows', () => {
|
||||
const rootGroup: ProjectGroup = {
|
||||
id: 'group-root',
|
||||
name: 'Platform',
|
||||
parentPath: '/monorepo',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const childGroup: ProjectGroup = {
|
||||
id: 'group-shared',
|
||||
name: 'packages/shared',
|
||||
parentPath: '/monorepo/packages/shared',
|
||||
parentGroupId: rootGroup.id,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 1,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 2,
|
||||
updatedAt: 2
|
||||
}
|
||||
const folderWorkspace: FolderWorkspace = {
|
||||
id: 'folder-workspace-nested',
|
||||
projectGroupId: childGroup.id,
|
||||
name: 'Shared package work',
|
||||
folderPath: '/monorepo/packages/shared',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 10,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 3,
|
||||
updatedAt: 3
|
||||
}
|
||||
|
||||
const rows = buildRows(
|
||||
'repo',
|
||||
[],
|
||||
new Map(),
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
[rootGroup, childGroup],
|
||||
new Set(),
|
||||
new Map(),
|
||||
[],
|
||||
[folderWorkspace]
|
||||
)
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
{
|
||||
type: 'header',
|
||||
key: 'project-group:group-root',
|
||||
projectGroupDepth: 0
|
||||
},
|
||||
{
|
||||
type: 'header',
|
||||
key: 'project-group:group-shared',
|
||||
projectGroupDepth: 1
|
||||
},
|
||||
{
|
||||
type: 'folder-workspace',
|
||||
folderWorkspace: { id: 'folder-workspace-nested' },
|
||||
groupDepth: 2
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not render folder workspaces under non-folder Project Groups', () => {
|
||||
const group: ProjectGroup = {
|
||||
id: 'group-manual',
|
||||
name: 'Manual',
|
||||
parentPath: null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const folderWorkspace: FolderWorkspace = {
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: group.id,
|
||||
name: 'Hidden',
|
||||
folderPath: '/monorepo',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 10,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
const rows = buildRows(
|
||||
'repo',
|
||||
[],
|
||||
new Map(),
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
[group],
|
||||
new Set(),
|
||||
new Map(),
|
||||
[],
|
||||
[folderWorkspace]
|
||||
)
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
{
|
||||
type: 'header',
|
||||
key: 'project-group:group-manual',
|
||||
count: 0
|
||||
}
|
||||
])
|
||||
expect(rows.some((row) => row.type === 'folder-workspace')).toBe(false)
|
||||
})
|
||||
|
||||
it('renders imported repos under nested Project Groups before worktree rows load', () => {
|
||||
const rootGroup: ProjectGroup = {
|
||||
id: 'group-root',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { CircleX, FolderTree, List, Pin } from 'lucide-react'
|
|||
import type React from 'react'
|
||||
import type {
|
||||
DetectedWorktree,
|
||||
FolderWorkspace,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
ProjectOrderBy,
|
||||
|
|
@ -10,7 +11,7 @@ import type {
|
|||
WorktreeLineage,
|
||||
WorkspaceStatusDefinition
|
||||
} from '../../../../shared/types'
|
||||
import { branchName } from '@/lib/git-utils'
|
||||
import { branchName } from '../../lib/git-utils'
|
||||
import {
|
||||
getWorkspaceStatus,
|
||||
getWorkspaceStatusFromGroupKey,
|
||||
|
|
@ -23,11 +24,11 @@ import {
|
|||
ConductorReviewIcon
|
||||
} from './workspace-status-icons'
|
||||
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key'
|
||||
import type { AppState } from '../../store/types'
|
||||
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key'
|
||||
import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../shared/project-groups'
|
||||
import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getRepoDisplayLabelsByPath } from '../../lib/repo-display-labels'
|
||||
import { translate } from '../../i18n/i18n'
|
||||
|
||||
export { branchName }
|
||||
|
||||
|
|
@ -78,13 +79,27 @@ export type PendingCreationRow = {
|
|||
repo: Repo | undefined
|
||||
}
|
||||
|
||||
export type FolderWorkspaceRow = {
|
||||
type: 'folder-workspace'
|
||||
key: string
|
||||
folderWorkspace: FolderWorkspace
|
||||
projectGroup: ProjectGroup
|
||||
depth: number
|
||||
groupDepth: number
|
||||
}
|
||||
|
||||
/** Minimal shape buildRows needs for an in-flight create. Deliberately not the
|
||||
* full PendingWorktreeCreation: row identity depends only on which creates
|
||||
* exist and their repo, so callers can subscribe on this stable shape and keep
|
||||
* progress-field churn (phase/loaderVisible) from rebuilding the whole list. */
|
||||
export type PendingCreationRef = { creationId: string; repoId: string }
|
||||
|
||||
export type Row = GroupHeaderRow | WorktreeRow | ImportedWorktreesCardRow | PendingCreationRow
|
||||
export type Row =
|
||||
| GroupHeaderRow
|
||||
| WorktreeRow
|
||||
| ImportedWorktreesCardRow
|
||||
| PendingCreationRow
|
||||
| FolderWorkspaceRow
|
||||
|
||||
function buildPendingCreationRow(
|
||||
creation: PendingCreationRef,
|
||||
|
|
@ -533,7 +548,8 @@ export function buildRows(
|
|||
projectGroups: readonly ProjectGroup[] = [],
|
||||
placeholderRepoIds: ReadonlySet<string> = new Set(),
|
||||
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map(),
|
||||
pendingCreations: readonly PendingCreationRef[] = []
|
||||
pendingCreations: readonly PendingCreationRef[] = [],
|
||||
folderWorkspaces: readonly FolderWorkspace[] = []
|
||||
): Row[] {
|
||||
const result: Row[] = []
|
||||
|
||||
|
|
@ -798,6 +814,23 @@ export function buildRows(
|
|||
}
|
||||
|
||||
const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group]))
|
||||
const folderWorkspacesByProjectGroupId = new Map<string, FolderWorkspace[]>()
|
||||
for (const workspace of folderWorkspaces) {
|
||||
const group = projectGroupsById.get(workspace.projectGroupId)
|
||||
if (!group?.parentPath) {
|
||||
continue
|
||||
}
|
||||
const list = folderWorkspacesByProjectGroupId.get(workspace.projectGroupId) ?? []
|
||||
list.push(workspace)
|
||||
folderWorkspacesByProjectGroupId.set(workspace.projectGroupId, list)
|
||||
}
|
||||
for (const list of folderWorkspacesByProjectGroupId.values()) {
|
||||
list.sort((left, right) => {
|
||||
const leftOrder = left.manualOrder ?? left.sortOrder
|
||||
const rightOrder = right.manualOrder ?? right.sortOrder
|
||||
return rightOrder - leftOrder || left.name.localeCompare(right.name)
|
||||
})
|
||||
}
|
||||
const childGroupsByParentId = new Map<string | null, ProjectGroup[]>()
|
||||
for (const group of projectGroups) {
|
||||
const parentId =
|
||||
|
|
@ -814,10 +847,11 @@ export function buildRows(
|
|||
|
||||
const getProjectGroupSubtreeCount = (groupId: string): number => {
|
||||
const directCount = groupByProjectGroupId.get(groupId)?.length ?? 0
|
||||
const folderWorkspaceCount = folderWorkspacesByProjectGroupId.get(groupId)?.length ?? 0
|
||||
const children = childGroupsByParentId.get(groupId) ?? []
|
||||
return children.reduce(
|
||||
(count, child) => count + getProjectGroupSubtreeCount(child.id),
|
||||
directCount
|
||||
directCount + folderWorkspaceCount
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -836,6 +870,16 @@ export function buildRows(
|
|||
projectGroupDepth: depth
|
||||
})
|
||||
if (!collapsedGroups.has(key)) {
|
||||
for (const folderWorkspace of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) {
|
||||
result.push({
|
||||
type: 'folder-workspace',
|
||||
key: `folder-workspace:${folderWorkspace.id}`,
|
||||
folderWorkspace,
|
||||
projectGroup,
|
||||
depth: 0,
|
||||
groupDepth: depth + 1
|
||||
})
|
||||
}
|
||||
appendOrderedGroups(withRepoSectionDisplayLabels(repoEntries), depth + 1)
|
||||
for (const childGroup of childGroups) {
|
||||
appendProjectGroup(childGroup, depth + 1)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
|
|||
import {
|
||||
WORKTREE_SECTION_HEADER_PADDING_LEFT,
|
||||
getProjectGroupHeaderPaddingLeft,
|
||||
getWorktreeCardContentIndent
|
||||
getWorktreeCardContentIndent,
|
||||
getWorktreeCardSurfaceInset
|
||||
} from './worktree-list-indentation'
|
||||
|
||||
describe('worktree list indentation', () => {
|
||||
|
|
@ -40,4 +41,13 @@ describe('worktree list indentation', () => {
|
|||
it('aligns flat section headers with top-level project headers', () => {
|
||||
expect(WORKTREE_SECTION_HEADER_PADDING_LEFT).toBe(getProjectGroupHeaderPaddingLeft(0))
|
||||
})
|
||||
|
||||
it('keeps root repo cards flush but insets cards inside project groups', () => {
|
||||
expect(getWorktreeCardSurfaceInset({ isGrouped: true, groupDepth: 0 })).toBe(0)
|
||||
expect(getWorktreeCardSurfaceInset({ isGrouped: true, groupDepth: 1 })).toBe(14)
|
||||
})
|
||||
|
||||
it('does not inset card surfaces outside grouped views', () => {
|
||||
expect(getWorktreeCardSurfaceInset({ isGrouped: false, groupDepth: 4 })).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ export const SIDEBAR_TREE_INDENT = 18
|
|||
// Why: project-grouped cards need to read as children even after the card
|
||||
// surface inset is subtracted, while lineage rows keep the base tree step.
|
||||
const PROJECT_WORKTREE_CARD_EXTRA_INDENT = 2
|
||||
// Why: grouped workspace cards should move their surface inward without using
|
||||
// the full tree step, preserving the existing compact child-card rhythm.
|
||||
const GROUPED_WORKTREE_CARD_SURFACE_INDENT = 14
|
||||
export const PROJECT_GROUP_HEADER_BASE_PADDING = 10
|
||||
// Why: workspace/status headers and project headers occupy the same sidebar
|
||||
// row role, so their titles should not shift when switching grouping modes.
|
||||
|
|
@ -29,3 +32,10 @@ export function getWorktreeCardContentIndent(args: {
|
|||
const projectCardIndent = args.isGrouped ? PROJECT_WORKTREE_CARD_EXTRA_INDENT : 0
|
||||
return (groupSteps + clampDepth(args.lineageDepth)) * SIDEBAR_TREE_INDENT + projectCardIndent
|
||||
}
|
||||
|
||||
export function getWorktreeCardSurfaceInset(args: {
|
||||
isGrouped: boolean
|
||||
groupDepth: number
|
||||
}): number {
|
||||
return args.isGrouped ? clampDepth(args.groupDepth) * GROUPED_WORKTREE_CARD_SURFACE_INDENT : 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export const GROUP_HEADER_ROW_HEIGHT = 28
|
|||
const SECONDARY_GROUP_HEADER_TOP_MARGIN = 4
|
||||
const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36
|
||||
const PENDING_CREATION_ROW_HEIGHT = 56
|
||||
const FOLDER_WORKSPACE_ROW_HEIGHT = 64
|
||||
|
||||
type WorktreeItemRow = Extract<Row, { type: 'item' }>
|
||||
export type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] }
|
||||
|
|
@ -50,6 +51,9 @@ export function estimateRenderRowSize(
|
|||
if (row?.type === 'pending-creation') {
|
||||
return PENDING_CREATION_ROW_HEIGHT
|
||||
}
|
||||
if (row?.type === 'folder-workspace') {
|
||||
return FOLDER_WORKSPACE_ROW_HEIGHT
|
||||
}
|
||||
return 116
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,14 @@ import { translate } from '@/i18n/i18n'
|
|||
|
||||
const RANGE_OPTIONS: ClaudeUsageRange[] = ['7d', '30d', '90d', 'all']
|
||||
const SCOPE_OPTIONS: { value: ClaudeUsageScope; label: string }[] = [
|
||||
{ value: 'orca', label: translate("auto.components.stats.ClaudeUsagePane.4f8368c272", "Orca worktrees only") },
|
||||
{ value: 'all', label: translate("auto.components.stats.ClaudeUsagePane.5ce4842c2c", "All local Claude usage") }
|
||||
{
|
||||
value: 'orca',
|
||||
label: translate('auto.components.stats.ClaudeUsagePane.4f8368c272', 'Orca worktrees only')
|
||||
},
|
||||
{
|
||||
value: 'all',
|
||||
label: translate('auto.components.stats.ClaudeUsagePane.5ce4842c2c', 'All local Claude usage')
|
||||
}
|
||||
]
|
||||
const RANGE_LABELS: Record<ClaudeUsageRange, string> = {
|
||||
'7d': 'Last 7 days',
|
||||
|
|
@ -108,15 +114,27 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.ClaudeUsagePane.6afacbee37", "Claude Usage Tracking")}</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{translate(
|
||||
'auto.components.stats.ClaudeUsagePane.6afacbee37',
|
||||
'Claude Usage Tracking'
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{translate("auto.components.stats.ClaudeUsagePane.0cb1a36d7d", "Reads local Claude usage logs to show token, model, and session stats.")}</p>
|
||||
{translate(
|
||||
'auto.components.stats.ClaudeUsagePane.0cb1a36d7d',
|
||||
'Reads local Claude usage logs to show token, model, and session stats.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={false}
|
||||
aria-label={translate("auto.components.stats.ClaudeUsagePane.424cd50412", "Enable Claude usage analytics")}
|
||||
aria-label={translate(
|
||||
'auto.components.stats.ClaudeUsagePane.424cd50412',
|
||||
'Enable Claude usage analytics'
|
||||
)}
|
||||
onClick={() => handleSetEnabled(true)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
|
||||
>
|
||||
|
|
@ -137,10 +155,18 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
<div className="space-y-4 rounded-lg border border-border/60 bg-card/30 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.ClaudeUsagePane.6afacbee37", "Claude Usage Tracking")}</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.6afacbee37', 'Claude Usage Tracking')}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{formatUpdatedAt(scanState.lastScanCompletedAt)}
|
||||
{scanState.lastScanError ? translate("auto.components.stats.ClaudeUsagePane.2d41fd45c6", " • Last scan error: {{value0}}", { value0: scanState.lastScanError }) : ''}
|
||||
{scanState.lastScanError
|
||||
? translate(
|
||||
'auto.components.stats.ClaudeUsagePane.2d41fd45c6',
|
||||
' • Last scan error: {{value0}}',
|
||||
{ value0: scanState.lastScanError }
|
||||
)
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 self-start">
|
||||
|
|
@ -152,17 +178,27 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={translate("auto.components.stats.ClaudeUsagePane.e9bf9fce0e", "Claude usage options")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.stats.ClaudeUsagePane.e9bf9fce0e',
|
||||
'Claude usage options'
|
||||
)}
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate("auto.components.stats.ClaudeUsagePane.dd29209b21", "Filters")}</TooltipContent>
|
||||
{translate('auto.components.stats.ClaudeUsagePane.dd29209b21', 'Filters')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuLabel>{translate("auto.components.stats.ClaudeUsagePane.f61cffb9c8", "Scope")}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{translate('auto.components.stats.ClaudeUsagePane.f61cffb9c8', 'Scope')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={scope}
|
||||
onValueChange={(value) => void setClaudeUsageScope(value as ClaudeUsageScope)}
|
||||
|
|
@ -174,7 +210,9 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{translate("auto.components.stats.ClaudeUsagePane.505be9aac4", "Range")}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{translate('auto.components.stats.ClaudeUsagePane.505be9aac4', 'Range')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={range}
|
||||
onValueChange={(value) => void setClaudeUsageRange(value as ClaudeUsageRange)}
|
||||
|
|
@ -195,20 +233,27 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
size="icon-xs"
|
||||
onClick={() => void refreshClaudeUsage()}
|
||||
disabled={scanState.isScanning}
|
||||
aria-label={translate("auto.components.stats.ClaudeUsagePane.c5b9b344d0", "Refresh Claude usage")}
|
||||
aria-label={translate(
|
||||
'auto.components.stats.ClaudeUsagePane.c5b9b344d0',
|
||||
'Refresh Claude usage'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={`size-3.5 ${scanState.isScanning ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate("auto.components.stats.ClaudeUsagePane.8d18bbb771", "Refresh")}</TooltipContent>
|
||||
{translate('auto.components.stats.ClaudeUsagePane.8d18bbb771', 'Refresh')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={true}
|
||||
aria-label={translate("auto.components.stats.ClaudeUsagePane.424cd50412", "Enable Claude usage analytics")}
|
||||
aria-label={translate(
|
||||
'auto.components.stats.ClaudeUsagePane.424cd50412',
|
||||
'Enable Claude usage analytics'
|
||||
)}
|
||||
onClick={() => handleSetEnabled(false)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
|
||||
>
|
||||
|
|
@ -225,32 +270,39 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
|
||||
{!hasAnyData ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-6 text-sm text-muted-foreground">
|
||||
{translate("auto.components.stats.ClaudeUsagePane.7dde9331fd", "No local Claude usage found yet for this scope.")}</div>
|
||||
{translate(
|
||||
'auto.components.stats.ClaudeUsagePane.7dde9331fd',
|
||||
'No local Claude usage found yet for this scope.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.ClaudeUsagePane.ea71fae8fc", "Input tokens")}
|
||||
label={translate('auto.components.stats.ClaudeUsagePane.ea71fae8fc', 'Input tokens')}
|
||||
value={formatTokens(summary?.inputTokens ?? 0)}
|
||||
icon={<Sparkles className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.ClaudeUsagePane.2b8a2f14aa", "Output tokens")}
|
||||
label={translate('auto.components.stats.ClaudeUsagePane.2b8a2f14aa', 'Output tokens')}
|
||||
value={formatTokens(summary?.outputTokens ?? 0)}
|
||||
icon={<Activity className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.ClaudeUsagePane.268cf0af51", "Cache read")}
|
||||
label={translate('auto.components.stats.ClaudeUsagePane.268cf0af51', 'Cache read')}
|
||||
value={formatTokens(summary?.cacheReadTokens ?? 0)}
|
||||
icon={<DatabaseZap className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.ClaudeUsagePane.b786fb4a70", "Cache write")}
|
||||
label={translate('auto.components.stats.ClaudeUsagePane.b786fb4a70', 'Cache write')}
|
||||
value={formatTokens(summary?.cacheWriteTokens ?? 0)}
|
||||
icon={<Waypoints className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.ClaudeUsagePane.1634c4f404", "Cache reuse rate")}
|
||||
label={translate(
|
||||
'auto.components.stats.ClaudeUsagePane.1634c4f404',
|
||||
'Cache reuse rate'
|
||||
)}
|
||||
value={
|
||||
summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined
|
||||
? `${Math.round(summary.cacheReuseRate * 100)}%`
|
||||
|
|
@ -259,7 +311,10 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
icon={<Gauge className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.ClaudeUsagePane.8cc23be4a3", "Zero-cache-read turns")}
|
||||
label={translate(
|
||||
'auto.components.stats.ClaudeUsagePane.8cc23be4a3',
|
||||
'Zero-cache-read turns'
|
||||
)}
|
||||
value={
|
||||
summary && summary.turns > 0
|
||||
? `${Math.round((summary.zeroCacheReadTurns / summary.turns) * 100)}%`
|
||||
|
|
@ -268,28 +323,41 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
icon={<DatabaseZap className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.ClaudeUsagePane.0f3e696ca9", "Sessions / Turns")}
|
||||
label={translate(
|
||||
'auto.components.stats.ClaudeUsagePane.0f3e696ca9',
|
||||
'Sessions / Turns'
|
||||
)}
|
||||
value={`${(summary?.sessions ?? 0).toLocaleString()} / ${(summary?.turns ?? 0).toLocaleString()}`}
|
||||
icon={<FolderKanban className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.ClaudeUsagePane.b26d4ddb58", "Est. API-equivalent cost")}
|
||||
label={translate(
|
||||
'auto.components.stats.ClaudeUsagePane.b26d4ddb58',
|
||||
'Est. API-equivalent cost'
|
||||
)}
|
||||
value={formatCost(summary?.estimatedCostUsd ?? null)}
|
||||
icon={<Coins className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
{translate("auto.components.stats.ClaudeUsagePane.51ae85fa00", "Cache reuse rate is calculated as cache read tokens / (input tokens + cache read tokens).")}</p>
|
||||
{translate(
|
||||
'auto.components.stats.ClaudeUsagePane.51ae85fa00',
|
||||
'Cache reuse rate is calculated as cache read tokens / (input tokens + cache read tokens).'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ClaudeUsageDailyChart daily={daily} />
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.ClaudeUsagePane.0f394c24e3", "By model")}</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.0f394c24e3', 'By model')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate("auto.components.stats.ClaudeUsagePane.c3fdbc5474", "Top model:")}{' '}
|
||||
{summary?.topModel ?? translate("auto.components.stats.ClaudeUsagePane.7765a4c3e1", "n/a")}
|
||||
{translate('auto.components.stats.ClaudeUsagePane.c3fdbc5474', 'Top model:')}{' '}
|
||||
{summary?.topModel ??
|
||||
translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -302,8 +370,10 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.sessions} {translate("auto.components.stats.ClaudeUsagePane.02a046792e", "sessions •")} {row.turns}{' '}
|
||||
{translate("auto.components.stats.ClaudeUsagePane.32176e1d44", "turns")}
|
||||
{row.sessions}{' '}
|
||||
{translate('auto.components.stats.ClaudeUsagePane.02a046792e', 'sessions •')}{' '}
|
||||
{row.turns}{' '}
|
||||
{translate('auto.components.stats.ClaudeUsagePane.32176e1d44', 'turns')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -312,10 +382,13 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.ClaudeUsagePane.7dc9e5613b", "By project")}</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.7dc9e5613b', 'By project')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate("auto.components.stats.ClaudeUsagePane.f97435845c", "Top project:")}{' '}
|
||||
{summary?.topProject ?? translate("auto.components.stats.ClaudeUsagePane.7765a4c3e1", "n/a")}
|
||||
{translate('auto.components.stats.ClaudeUsagePane.f97435845c', 'Top project:')}{' '}
|
||||
{summary?.topProject ??
|
||||
translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -328,8 +401,10 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.sessions} {translate("auto.components.stats.ClaudeUsagePane.02a046792e", "sessions •")} {row.turns}{' '}
|
||||
{translate("auto.components.stats.ClaudeUsagePane.32176e1d44", "turns")}
|
||||
{row.sessions}{' '}
|
||||
{translate('auto.components.stats.ClaudeUsagePane.02a046792e', 'sessions •')}{' '}
|
||||
{row.turns}{' '}
|
||||
{translate('auto.components.stats.ClaudeUsagePane.32176e1d44', 'turns')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -339,25 +414,41 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.ClaudeUsagePane.7e76c84153", "Recent sessions")}</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.7e76c84153', 'Recent sessions')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate("auto.components.stats.ClaudeUsagePane.abfc4a4943", "Cache reuse rate:")}{' '}
|
||||
{translate('auto.components.stats.ClaudeUsagePane.abfc4a4943', 'Cache reuse rate:')}{' '}
|
||||
{summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined
|
||||
? `${Math.round(summary.cacheReuseRate * 100)}%`
|
||||
: translate("auto.components.stats.ClaudeUsagePane.7765a4c3e1", "n/a")}
|
||||
: translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.01476891c7", "Last active")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.c17bed0416", "Project")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.1afc25eb06", "Model")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.0f03975d59", "Turns")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.faf3444859", "Input")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.a8b7487ff7", "Output")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.21ea00bfa8", "Cache")}</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.01476891c7', 'Last active')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.c17bed0416', 'Project')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.1afc25eb06', 'Model')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.0f03975d59', 'Turns')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.faf3444859', 'Input')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.a8b7487ff7', 'Output')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.21ea00bfa8', 'Cache')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -367,7 +458,10 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
{formatSessionTime(row.lastActiveAt)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{row.model ?? translate("auto.components.stats.ClaudeUsagePane.cfe2282ffa", "Unknown")}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{row.model ??
|
||||
translate('auto.components.stats.ClaudeUsagePane.cfe2282ffa', 'Unknown')}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{row.turns}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.inputTokens)}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,14 @@ import { translate } from '@/i18n/i18n'
|
|||
|
||||
const RANGE_OPTIONS: CodexUsageRange[] = ['7d', '30d', '90d', 'all']
|
||||
const SCOPE_OPTIONS: { value: CodexUsageScope; label: string }[] = [
|
||||
{ value: 'orca', label: translate("auto.components.stats.CodexUsagePane.201766b754", "Orca worktrees only") },
|
||||
{ value: 'all', label: translate("auto.components.stats.CodexUsagePane.4fe8820098", "All local Codex usage") }
|
||||
{
|
||||
value: 'orca',
|
||||
label: translate('auto.components.stats.CodexUsagePane.201766b754', 'Orca worktrees only')
|
||||
},
|
||||
{
|
||||
value: 'all',
|
||||
label: translate('auto.components.stats.CodexUsagePane.4fe8820098', 'All local Codex usage')
|
||||
}
|
||||
]
|
||||
const RANGE_LABELS: Record<CodexUsageRange, string> = {
|
||||
'7d': 'Last 7 days',
|
||||
|
|
@ -107,15 +113,24 @@ export function CodexUsagePane(): React.JSX.Element {
|
|||
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.CodexUsagePane.408210470c", "Codex Usage Tracking")}</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.CodexUsagePane.408210470c', 'Codex Usage Tracking')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{translate("auto.components.stats.CodexUsagePane.13badcd8f2", "Reads local Codex usage logs to show token, model, and session stats.")}</p>
|
||||
{translate(
|
||||
'auto.components.stats.CodexUsagePane.13badcd8f2',
|
||||
'Reads local Codex usage logs to show token, model, and session stats.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={false}
|
||||
aria-label={translate("auto.components.stats.CodexUsagePane.f7c1affbd5", "Enable Codex usage analytics")}
|
||||
aria-label={translate(
|
||||
'auto.components.stats.CodexUsagePane.f7c1affbd5',
|
||||
'Enable Codex usage analytics'
|
||||
)}
|
||||
onClick={() => handleSetEnabled(true)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
|
||||
>
|
||||
|
|
@ -129,7 +144,7 @@ export function CodexUsagePane(): React.JSX.Element {
|
|||
if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) {
|
||||
return (
|
||||
<ClaudeUsageLoadingState
|
||||
title={translate("auto.components.stats.CodexUsagePane.408210470c", "Codex Usage Tracking")}
|
||||
title={translate('auto.components.stats.CodexUsagePane.408210470c', 'Codex Usage Tracking')}
|
||||
summaryCardCount={6}
|
||||
summaryGridClassName="md:grid-cols-3"
|
||||
/>
|
||||
|
|
@ -142,10 +157,18 @@ export function CodexUsagePane(): React.JSX.Element {
|
|||
<div className="space-y-4 rounded-lg border border-border/60 bg-card/30 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.CodexUsagePane.408210470c", "Codex Usage Tracking")}</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.CodexUsagePane.408210470c', 'Codex Usage Tracking')}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{formatUpdatedAt(scanState.lastScanCompletedAt)}
|
||||
{scanState.lastScanError ? translate("auto.components.stats.CodexUsagePane.8a6655f7a2", " • Last scan error: {{value0}}", { value0: scanState.lastScanError }) : ''}
|
||||
{scanState.lastScanError
|
||||
? translate(
|
||||
'auto.components.stats.CodexUsagePane.8a6655f7a2',
|
||||
' • Last scan error: {{value0}}',
|
||||
{ value0: scanState.lastScanError }
|
||||
)
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 self-start">
|
||||
|
|
@ -157,17 +180,27 @@ export function CodexUsagePane(): React.JSX.Element {
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={translate("auto.components.stats.CodexUsagePane.70b5b8581f", "Codex usage options")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.stats.CodexUsagePane.70b5b8581f',
|
||||
'Codex usage options'
|
||||
)}
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate("auto.components.stats.CodexUsagePane.1af1a39b2f", "Filters")}</TooltipContent>
|
||||
{translate('auto.components.stats.CodexUsagePane.1af1a39b2f', 'Filters')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuLabel>{translate("auto.components.stats.CodexUsagePane.6d68e8399a", "Scope")}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{translate('auto.components.stats.CodexUsagePane.6d68e8399a', 'Scope')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={scope}
|
||||
onValueChange={(value) => void setCodexUsageScope(value as CodexUsageScope)}
|
||||
|
|
@ -179,7 +212,9 @@ export function CodexUsagePane(): React.JSX.Element {
|
|||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{translate("auto.components.stats.CodexUsagePane.89162e019b", "Range")}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{translate('auto.components.stats.CodexUsagePane.89162e019b', 'Range')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={range}
|
||||
onValueChange={(value) => void setCodexUsageRange(value as CodexUsageRange)}
|
||||
|
|
@ -200,20 +235,27 @@ export function CodexUsagePane(): React.JSX.Element {
|
|||
size="icon-xs"
|
||||
onClick={() => void refreshCodexUsage()}
|
||||
disabled={scanState.isScanning}
|
||||
aria-label={translate("auto.components.stats.CodexUsagePane.ec4d270e2c", "Refresh Codex usage")}
|
||||
aria-label={translate(
|
||||
'auto.components.stats.CodexUsagePane.ec4d270e2c',
|
||||
'Refresh Codex usage'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={`size-3.5 ${scanState.isScanning ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate("auto.components.stats.CodexUsagePane.3022cda443", "Refresh")}</TooltipContent>
|
||||
{translate('auto.components.stats.CodexUsagePane.3022cda443', 'Refresh')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={true}
|
||||
aria-label={translate("auto.components.stats.CodexUsagePane.f7c1affbd5", "Enable Codex usage analytics")}
|
||||
aria-label={translate(
|
||||
'auto.components.stats.CodexUsagePane.f7c1affbd5',
|
||||
'Enable Codex usage analytics'
|
||||
)}
|
||||
onClick={() => handleSetEnabled(false)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
|
||||
>
|
||||
|
|
@ -230,53 +272,73 @@ export function CodexUsagePane(): React.JSX.Element {
|
|||
|
||||
{!hasAnyData ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-6 text-sm text-muted-foreground">
|
||||
{translate("auto.components.stats.CodexUsagePane.4c865393b4", "No local Codex usage found yet for this scope.")}</div>
|
||||
{translate(
|
||||
'auto.components.stats.CodexUsagePane.4c865393b4',
|
||||
'No local Codex usage found yet for this scope.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.CodexUsagePane.e365eaa6fd", "Input tokens")}
|
||||
label={translate('auto.components.stats.CodexUsagePane.e365eaa6fd', 'Input tokens')}
|
||||
value={formatTokens(summary?.inputTokens ?? 0)}
|
||||
icon={<Sparkles className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.CodexUsagePane.5d8eba87bd", "Output tokens")}
|
||||
label={translate('auto.components.stats.CodexUsagePane.5d8eba87bd', 'Output tokens')}
|
||||
value={formatTokens(summary?.outputTokens ?? 0)}
|
||||
icon={<Activity className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.CodexUsagePane.a9ac0f423a", "Cached input")}
|
||||
label={translate('auto.components.stats.CodexUsagePane.a9ac0f423a', 'Cached input')}
|
||||
value={formatTokens(summary?.cachedInputTokens ?? 0)}
|
||||
icon={<DatabaseZap className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.CodexUsagePane.6e18146e9b", "Reasoning output")}
|
||||
label={translate(
|
||||
'auto.components.stats.CodexUsagePane.6e18146e9b',
|
||||
'Reasoning output'
|
||||
)}
|
||||
value={formatTokens(summary?.reasoningOutputTokens ?? 0)}
|
||||
icon={<Brain className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.CodexUsagePane.907b31865f", "Sessions / Events")}
|
||||
label={translate(
|
||||
'auto.components.stats.CodexUsagePane.907b31865f',
|
||||
'Sessions / Events'
|
||||
)}
|
||||
value={`${(summary?.sessions ?? 0).toLocaleString()} / ${(summary?.events ?? 0).toLocaleString()}`}
|
||||
icon={<FolderKanban className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.CodexUsagePane.1a18fbd56b", "Est. API-equivalent cost")}
|
||||
label={translate(
|
||||
'auto.components.stats.CodexUsagePane.1a18fbd56b',
|
||||
'Est. API-equivalent cost'
|
||||
)}
|
||||
value={formatCost(summary?.estimatedCostUsd ?? null)}
|
||||
icon={<Coins className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
{translate("auto.components.stats.CodexUsagePane.94ac1f1ee7", "Reasoning tokens are shown for visibility, but cost is calculated from uncached input, cached input, and output only.")}</p>
|
||||
{translate(
|
||||
'auto.components.stats.CodexUsagePane.94ac1f1ee7',
|
||||
'Reasoning tokens are shown for visibility, but cost is calculated from uncached input, cached input, and output only.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<CodexUsageDailyChart daily={daily} />
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.CodexUsagePane.5a0d1d69cd", "By model")}</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.CodexUsagePane.5a0d1d69cd', 'By model')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate("auto.components.stats.CodexUsagePane.95d2d89285", "Top model:")}{' '}
|
||||
{summary?.topModel ?? translate("auto.components.stats.CodexUsagePane.ae255c3dba", "n/a")}
|
||||
{translate('auto.components.stats.CodexUsagePane.95d2d89285', 'Top model:')}{' '}
|
||||
{summary?.topModel ??
|
||||
translate('auto.components.stats.CodexUsagePane.ae255c3dba', 'n/a')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -289,10 +351,12 @@ export function CodexUsagePane(): React.JSX.Element {
|
|||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.sessions} {translate("auto.components.stats.CodexUsagePane.bf1bf2f674", "sessions •")} {row.events}{' '}
|
||||
{translate("auto.components.stats.CodexUsagePane.79a69522a5", "events")}
|
||||
{row.sessions}{' '}
|
||||
{translate('auto.components.stats.CodexUsagePane.bf1bf2f674', 'sessions •')}{' '}
|
||||
{row.events}{' '}
|
||||
{translate('auto.components.stats.CodexUsagePane.79a69522a5', 'events')}
|
||||
{row.hasInferredPricing
|
||||
? ` ${translate("auto.components.stats.CodexUsagePane.247c93ca92", "• inferred pricing")}`
|
||||
? ` ${translate('auto.components.stats.CodexUsagePane.247c93ca92', '• inferred pricing')}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -302,10 +366,13 @@ export function CodexUsagePane(): React.JSX.Element {
|
|||
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.CodexUsagePane.b98718aaab", "By project")}</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.CodexUsagePane.b98718aaab', 'By project')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate("auto.components.stats.CodexUsagePane.829ee743f2", "Top project:")}{' '}
|
||||
{summary?.topProject ?? translate("auto.components.stats.CodexUsagePane.ae255c3dba", "n/a")}
|
||||
{translate('auto.components.stats.CodexUsagePane.829ee743f2', 'Top project:')}{' '}
|
||||
{summary?.topProject ??
|
||||
translate('auto.components.stats.CodexUsagePane.ae255c3dba', 'n/a')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -318,8 +385,10 @@ export function CodexUsagePane(): React.JSX.Element {
|
|||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.sessions} {translate("auto.components.stats.CodexUsagePane.bf1bf2f674", "sessions •")} {row.events}{' '}
|
||||
{translate("auto.components.stats.CodexUsagePane.79a69522a5", "events")}
|
||||
{row.sessions}{' '}
|
||||
{translate('auto.components.stats.CodexUsagePane.bf1bf2f674', 'sessions •')}{' '}
|
||||
{row.events}{' '}
|
||||
{translate('auto.components.stats.CodexUsagePane.79a69522a5', 'events')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -329,21 +398,41 @@ export function CodexUsagePane(): React.JSX.Element {
|
|||
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.CodexUsagePane.0cb0983c07", "Recent sessions")}</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.CodexUsagePane.0cb0983c07', 'Recent sessions')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate("auto.components.stats.CodexUsagePane.0bd8655475", "Most recent local Codex sessions in this scope.")}</p>
|
||||
{translate(
|
||||
'auto.components.stats.CodexUsagePane.0bd8655475',
|
||||
'Most recent local Codex sessions in this scope.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.0c36b100be", "Last active")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.1a65900aea", "Project")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.c2478bcc3c", "Model")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.bd0822ca47", "Events")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.3acc582214", "Input")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.bbd20344b8", "Output")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.e0b988599d", "Total")}</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.0c36b100be', 'Last active')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.1a65900aea', 'Project')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.c2478bcc3c', 'Model')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.bd0822ca47', 'Events')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.3acc582214', 'Input')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.bbd20344b8', 'Output')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.e0b988599d', 'Total')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -354,7 +443,8 @@ export function CodexUsagePane(): React.JSX.Element {
|
|||
</td>
|
||||
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{row.model ?? translate("auto.components.stats.CodexUsagePane.bf6cf2d4dd", "Unknown")}
|
||||
{row.model ??
|
||||
translate('auto.components.stats.CodexUsagePane.bf6cf2d4dd', 'Unknown')}
|
||||
{row.hasInferredPricing ? ' *' : ''}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{row.events}</td>
|
||||
|
|
|
|||
|
|
@ -32,8 +32,17 @@ import { translate } from '@/i18n/i18n'
|
|||
|
||||
const RANGE_OPTIONS: OpenCodeUsageRange[] = ['7d', '30d', '90d', 'all']
|
||||
const SCOPE_OPTIONS: { value: OpenCodeUsageScope; label: string }[] = [
|
||||
{ value: 'orca', label: translate("auto.components.stats.OpenCodeUsagePane.e04c58327c", "Orca worktrees only") },
|
||||
{ value: 'all', label: translate("auto.components.stats.OpenCodeUsagePane.144a6050e9", "All local OpenCode usage") }
|
||||
{
|
||||
value: 'orca',
|
||||
label: translate('auto.components.stats.OpenCodeUsagePane.e04c58327c', 'Orca worktrees only')
|
||||
},
|
||||
{
|
||||
value: 'all',
|
||||
label: translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.144a6050e9',
|
||||
'All local OpenCode usage'
|
||||
)
|
||||
}
|
||||
]
|
||||
const RANGE_LABELS: Record<OpenCodeUsageRange, string> = {
|
||||
'7d': 'Last 7 days',
|
||||
|
|
@ -109,15 +118,27 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
|||
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.OpenCodeUsagePane.bea80ceae0", "OpenCode Usage Tracking")}</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.bea80ceae0',
|
||||
'OpenCode Usage Tracking'
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{translate("auto.components.stats.OpenCodeUsagePane.b8b3522436", "Reads local OpenCode usage logs to show token, model, and session stats.")}</p>
|
||||
{translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.b8b3522436',
|
||||
'Reads local OpenCode usage logs to show token, model, and session stats.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={false}
|
||||
aria-label={translate("auto.components.stats.OpenCodeUsagePane.f04131b3be", "Enable OpenCode usage analytics")}
|
||||
aria-label={translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.f04131b3be',
|
||||
'Enable OpenCode usage analytics'
|
||||
)}
|
||||
onClick={() => handleSetEnabled(true)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
|
||||
>
|
||||
|
|
@ -131,7 +152,10 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
|||
if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) {
|
||||
return (
|
||||
<ClaudeUsageLoadingState
|
||||
title={translate("auto.components.stats.OpenCodeUsagePane.bea80ceae0", "OpenCode Usage Tracking")}
|
||||
title={translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.bea80ceae0',
|
||||
'OpenCode Usage Tracking'
|
||||
)}
|
||||
summaryCardCount={6}
|
||||
summaryGridClassName="md:grid-cols-3"
|
||||
/>
|
||||
|
|
@ -144,10 +168,21 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
|||
<div className="space-y-4 rounded-lg border border-border/60 bg-card/30 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.OpenCodeUsagePane.bea80ceae0", "OpenCode Usage Tracking")}</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.bea80ceae0',
|
||||
'OpenCode Usage Tracking'
|
||||
)}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{formatUpdatedAt(scanState.lastScanCompletedAt)}
|
||||
{scanState.lastScanError ? translate("auto.components.stats.OpenCodeUsagePane.6cc7782458", " • Last scan error: {{value0}}", { value0: scanState.lastScanError }) : ''}
|
||||
{scanState.lastScanError
|
||||
? translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.6cc7782458',
|
||||
' • Last scan error: {{value0}}',
|
||||
{ value0: scanState.lastScanError }
|
||||
)
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 self-start">
|
||||
|
|
@ -156,17 +191,27 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={translate("auto.components.stats.OpenCodeUsagePane.230d6de108", "OpenCode usage options")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.230d6de108',
|
||||
'OpenCode usage options'
|
||||
)}
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate("auto.components.stats.OpenCodeUsagePane.01583b30aa", "Filters")}</TooltipContent>
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.01583b30aa', 'Filters')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuLabel>{translate("auto.components.stats.OpenCodeUsagePane.40d283c837", "Scope")}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.40d283c837', 'Scope')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={scope}
|
||||
onValueChange={(value) => void setOpenCodeUsageScope(value as OpenCodeUsageScope)}
|
||||
|
|
@ -178,7 +223,9 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
|||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{translate("auto.components.stats.OpenCodeUsagePane.b5ed5c9fd0", "Range")}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.b5ed5c9fd0', 'Range')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={range}
|
||||
onValueChange={(value) => void setOpenCodeUsageRange(value as OpenCodeUsageRange)}
|
||||
|
|
@ -199,20 +246,27 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
|||
size="icon-xs"
|
||||
onClick={() => void refreshOpenCodeUsage()}
|
||||
disabled={scanState.isScanning}
|
||||
aria-label={translate("auto.components.stats.OpenCodeUsagePane.bed558df0b", "Refresh OpenCode usage")}
|
||||
aria-label={translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.bed558df0b',
|
||||
'Refresh OpenCode usage'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={`size-3.5 ${scanState.isScanning ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate("auto.components.stats.OpenCodeUsagePane.603cd138dc", "Refresh")}</TooltipContent>
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.603cd138dc', 'Refresh')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={true}
|
||||
aria-label={translate("auto.components.stats.OpenCodeUsagePane.f04131b3be", "Enable OpenCode usage analytics")}
|
||||
aria-label={translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.f04131b3be',
|
||||
'Enable OpenCode usage analytics'
|
||||
)}
|
||||
onClick={() => handleSetEnabled(false)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
|
||||
>
|
||||
|
|
@ -229,53 +283,82 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
|||
|
||||
{!hasAnyData ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-6 text-sm text-muted-foreground">
|
||||
{translate("auto.components.stats.OpenCodeUsagePane.bb6363e08c", "No local OpenCode usage found yet for this scope.")}</div>
|
||||
{translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.bb6363e08c',
|
||||
'No local OpenCode usage found yet for this scope.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.OpenCodeUsagePane.d637a892ed", "Input tokens")}
|
||||
label={translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.d637a892ed',
|
||||
'Input tokens'
|
||||
)}
|
||||
value={formatTokens(summary?.inputTokens ?? 0)}
|
||||
icon={<Sparkles className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.OpenCodeUsagePane.7aa4d8ce35", "Output tokens")}
|
||||
label={translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.7aa4d8ce35',
|
||||
'Output tokens'
|
||||
)}
|
||||
value={formatTokens(summary?.outputTokens ?? 0)}
|
||||
icon={<Activity className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.OpenCodeUsagePane.603504ee3b", "Cached input")}
|
||||
label={translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.603504ee3b',
|
||||
'Cached input'
|
||||
)}
|
||||
value={formatTokens(summary?.cachedInputTokens ?? 0)}
|
||||
icon={<DatabaseZap className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.OpenCodeUsagePane.5a65d68b77", "Reasoning output")}
|
||||
label={translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.5a65d68b77',
|
||||
'Reasoning output'
|
||||
)}
|
||||
value={formatTokens(summary?.reasoningOutputTokens ?? 0)}
|
||||
icon={<Brain className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.OpenCodeUsagePane.7e9433469a", "Sessions / Events")}
|
||||
label={translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.7e9433469a',
|
||||
'Sessions / Events'
|
||||
)}
|
||||
value={`${(summary?.sessions ?? 0).toLocaleString()} / ${(summary?.events ?? 0).toLocaleString()}`}
|
||||
icon={<FolderKanban className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={translate("auto.components.stats.OpenCodeUsagePane.15c34d4b08", "Recorded cost")}
|
||||
label={translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.15c34d4b08',
|
||||
'Recorded cost'
|
||||
)}
|
||||
value={formatCost(summary?.estimatedCostUsd ?? null)}
|
||||
icon={<Coins className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
{translate("auto.components.stats.OpenCodeUsagePane.e5bb23d85e", "Cost comes from the local OpenCode database when the assistant message recorded one.")}</p>
|
||||
{translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.e5bb23d85e',
|
||||
'Cost comes from the local OpenCode database when the assistant message recorded one.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<CodexUsageDailyChart daily={daily} />
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.OpenCodeUsagePane.040c044d39", "By model")}</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.040c044d39', 'By model')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate("auto.components.stats.OpenCodeUsagePane.a15206a63a", "Top model:")}{' '}
|
||||
{summary?.topModel ?? translate("auto.components.stats.OpenCodeUsagePane.8095a63426", "n/a")}
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.a15206a63a', 'Top model:')}{' '}
|
||||
{summary?.topModel ??
|
||||
translate('auto.components.stats.OpenCodeUsagePane.8095a63426', 'n/a')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -288,9 +371,16 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
|||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.sessions} {translate("auto.components.stats.OpenCodeUsagePane.bc0cb89901", "sessions •")} {row.events}{' '}
|
||||
{translate("auto.components.stats.OpenCodeUsagePane.1e5d410df0", "events")}
|
||||
{row.estimatedCostUsd !== null ? ` • ${formatCost(row.estimatedCostUsd)}` : ''}
|
||||
{row.sessions}{' '}
|
||||
{translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.bc0cb89901',
|
||||
'sessions •'
|
||||
)}{' '}
|
||||
{row.events}{' '}
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.1e5d410df0', 'events')}
|
||||
{row.estimatedCostUsd !== null
|
||||
? ` • ${formatCost(row.estimatedCostUsd)}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -299,10 +389,13 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
|||
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.OpenCodeUsagePane.0f0a1684bb", "By project")}</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.0f0a1684bb', 'By project')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate("auto.components.stats.OpenCodeUsagePane.048ffe4d65", "Top project:")}{' '}
|
||||
{summary?.topProject ?? translate("auto.components.stats.OpenCodeUsagePane.8095a63426", "n/a")}
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.048ffe4d65', 'Top project:')}{' '}
|
||||
{summary?.topProject ??
|
||||
translate('auto.components.stats.OpenCodeUsagePane.8095a63426', 'n/a')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -315,8 +408,13 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
|||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.sessions} {translate("auto.components.stats.OpenCodeUsagePane.bc0cb89901", "sessions •")} {row.events}{' '}
|
||||
{translate("auto.components.stats.OpenCodeUsagePane.1e5d410df0", "events")}
|
||||
{row.sessions}{' '}
|
||||
{translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.bc0cb89901',
|
||||
'sessions •'
|
||||
)}{' '}
|
||||
{row.events}{' '}
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.1e5d410df0', 'events')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -326,21 +424,44 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
|||
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.OpenCodeUsagePane.4799177b1c", "Recent sessions")}</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.4799177b1c', 'Recent sessions')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate("auto.components.stats.OpenCodeUsagePane.81817a641a", "Most recent local OpenCode sessions in this scope.")}</p>
|
||||
{translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.81817a641a',
|
||||
'Most recent local OpenCode sessions in this scope.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.d97bdf6e27", "Last active")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.a4738de041", "Project")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.08c78441b7", "Model")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.d416f5cf92", "Events")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.0f2f266c9d", "Input")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.dfc4513657", "Output")}</th>
|
||||
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.349f7c3f5c", "Total")}</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.d97bdf6e27',
|
||||
'Last active'
|
||||
)}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.a4738de041', 'Project')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.08c78441b7', 'Model')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.d416f5cf92', 'Events')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.0f2f266c9d', 'Input')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.dfc4513657', 'Output')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.349f7c3f5c', 'Total')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -350,7 +471,13 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
|||
{formatSessionTime(row.lastActiveAt)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{row.model ?? translate("auto.components.stats.OpenCodeUsagePane.362231082f", "Unknown")}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{row.model ??
|
||||
translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.362231082f',
|
||||
'Unknown'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{row.events}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.inputTokens)}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import {
|
|||
} from '@/lib/agent-status'
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport'
|
||||
import { createIpcPtyTransport } from './pty-transport'
|
||||
import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding'
|
||||
import type { PtyConnectionDeps } from './pty-connection-types'
|
||||
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
|
||||
|
|
@ -1414,19 +1416,31 @@ export function connectPanePty(
|
|||
// callbacks to the correct Orca pane without resolving worktrees from cwd.
|
||||
// The key matches the `${tabId}:${leafId}` composite used for cacheTimerByKey
|
||||
// and agentStatusByPaneKey. Treat it as opaque outside Orca.
|
||||
const state = useAppStore.getState()
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(deps.worktreeId)
|
||||
const folderWorkspace =
|
||||
parsedWorkspaceKey?.type === 'folder'
|
||||
? state.folderWorkspaces.find(
|
||||
(workspace) => workspace.id === parsedWorkspaceKey.folderWorkspaceId
|
||||
)
|
||||
: null
|
||||
const workspaceEnv: Record<string, string> = { ORCA_WORKSPACE_ID: deps.worktreeId }
|
||||
if (folderWorkspace) {
|
||||
workspaceEnv.ORCA_PROJECT_GROUP_ID = folderWorkspace.projectGroupId
|
||||
workspaceEnv.ORCA_WORKSPACE_ROOT = folderWorkspace.folderPath
|
||||
}
|
||||
const paneEnv = {
|
||||
...paneStartup?.env,
|
||||
...workspaceEnv,
|
||||
ORCA_PANE_KEY: cacheKey,
|
||||
ORCA_TAB_ID: deps.tabId,
|
||||
ORCA_WORKTREE_ID: deps.worktreeId
|
||||
}
|
||||
|
||||
// Why: remote repos route PTY spawn through the SSH provider. Resolve the
|
||||
// repo's connectionId from the store so the transport passes it to pty:spawn.
|
||||
const state = useAppStore.getState()
|
||||
// Why: folder workspaces can inherit their SSH target from child repos, so
|
||||
// use the shared resolver instead of only looking up repo-backed worktrees.
|
||||
const worktree = getWorktreeMapFromState(state).get(deps.worktreeId)
|
||||
const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null
|
||||
const connectionId = repo?.connectionId ?? null
|
||||
const connectionId = getConnectionId(deps.worktreeId) ?? null
|
||||
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find((t) => t.id === deps.tabId)
|
||||
const shellOverride = tab?.shellOverride
|
||||
const isNativeWindowsConpty = isLocalNativeWindowsPty({
|
||||
|
|
|
|||
|
|
@ -776,6 +776,7 @@ export function useIpcEvents(): void {
|
|||
if (event.type === 'reposChanged') {
|
||||
const state = useAppStore.getState()
|
||||
void state.fetchProjectGroups()
|
||||
void state.fetchFolderWorkspaces()
|
||||
void state.fetchRepos()
|
||||
return
|
||||
}
|
||||
|
|
@ -839,6 +840,7 @@ export function useIpcEvents(): void {
|
|||
}
|
||||
const state = useAppStore.getState()
|
||||
void state.fetchProjectGroups()
|
||||
void state.fetchFolderWorkspaces()
|
||||
void state.fetchRepos()
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3074,7 +3074,17 @@
|
|||
"8db50afe1a": "Import repositories from folder",
|
||||
"5b2e6fe3c8": "Import separately",
|
||||
"cf9d382ca1": "Import",
|
||||
"220dd32d83": "Scanning..."
|
||||
"220dd32d83": "Scanning...",
|
||||
"fb33359f69": "Is this a monorepo?",
|
||||
"d75170194e": "Choose this if these projects belong together. Orca will group them and let you work from the parent folder.",
|
||||
"39d51212cc": "Monorepo name",
|
||||
"e907ec8935": "What is a monorepo name?",
|
||||
"aa0247680d": "No, import separately",
|
||||
"a0bc4d1f8e": "Yes, import as monorepo",
|
||||
"8401a7a0d0": "1 repository",
|
||||
"d4f1df62ef": "{{value0}} repositories",
|
||||
"b4263a2ac4": "Found {{value0}} in {{value1}}.",
|
||||
"24eda6c8b2": "Scanning... {{value0}}"
|
||||
},
|
||||
"AddRepoRemoteStep": {
|
||||
"5b205b5281": "Stop scan",
|
||||
|
|
@ -3554,7 +3564,8 @@
|
|||
"f50603c6b2": "Mark Unread",
|
||||
"8dacff1fe0": "Mark Read",
|
||||
"3baa7d6507": "Pin",
|
||||
"697d0f6e1b": "Unpin"
|
||||
"697d0f6e1b": "Unpin",
|
||||
"250de158fd": "Remove Workspace"
|
||||
},
|
||||
"WorktreeList": {
|
||||
"d880ea0744": "Create a group and move this project into it.",
|
||||
|
|
@ -3588,6 +3599,7 @@
|
|||
"c1f4a31623": "Show {{value0}} child workspaces",
|
||||
"e97297cb75": "Hide {{value0}} child workspace",
|
||||
"0cd15956d4": "Hide {{value0}} child workspaces",
|
||||
"bd37a57ac8": "Create workspace for {{value0}}",
|
||||
"b667b59632": "Some projects could not be removed from Orca",
|
||||
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
|
||||
"groupDeleteFailed": "Failed to delete group",
|
||||
|
|
@ -3795,6 +3807,15 @@
|
|||
},
|
||||
"index": {
|
||||
"b826a98b6f": "busy"
|
||||
},
|
||||
"FolderWorkspaceComposerDialog": {
|
||||
"connectFailed": "Failed to connect to project.",
|
||||
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
|
||||
"title": "Create Folder Workspace",
|
||||
"createStart": "Create & Start Agent",
|
||||
"create": "Create Workspace",
|
||||
"sourceProject": "Task Source",
|
||||
"chooseSourceProject": "Choose task source"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
|
|
|||
|
|
@ -3074,7 +3074,17 @@
|
|||
"8db50afe1a": "Importar repositorios desde la carpeta",
|
||||
"5b2e6fe3c8": "Importar por separado",
|
||||
"cf9d382ca1": "Importar",
|
||||
"220dd32d83": "Exploración..."
|
||||
"220dd32d83": "Exploración...",
|
||||
"fb33359f69": "¿Es esto un monorepo?",
|
||||
"d75170194e": "Elige esto si estos proyectos pertenecen juntos. Orca los agrupará y te permitirá trabajar desde la carpeta principal.",
|
||||
"39d51212cc": "Nombre del monorepo",
|
||||
"e907ec8935": "¿Qué es un nombre de monorepo?",
|
||||
"aa0247680d": "No, importar por separado",
|
||||
"a0bc4d1f8e": "Sí, importar como monorepo",
|
||||
"8401a7a0d0": "1 repositorio",
|
||||
"d4f1df62ef": "{{value0}} repositorios",
|
||||
"b4263a2ac4": "Se encontraron {{value0}} en {{value1}}.",
|
||||
"24eda6c8b2": "Explorando... {{value0}}"
|
||||
},
|
||||
"AddRepoRemoteStep": {
|
||||
"5b205b5281": "Detener escaneo",
|
||||
|
|
@ -3554,7 +3564,8 @@
|
|||
"f50603c6b2": "Marcar como no leído",
|
||||
"8dacff1fe0": "Marcar como leído",
|
||||
"3baa7d6507": "Alfiler",
|
||||
"697d0f6e1b": "Desprender"
|
||||
"697d0f6e1b": "Desprender",
|
||||
"250de158fd": "Remove Workspace"
|
||||
},
|
||||
"WorktreeList": {
|
||||
"d880ea0744": "Crea un grupo y mueve este proyecto a él.",
|
||||
|
|
@ -3588,6 +3599,7 @@
|
|||
"c1f4a31623": "Mostrar {{value0}} espacios de trabajo secundarios",
|
||||
"e97297cb75": "Ocultar {{value0}} espacio de trabajo secundario",
|
||||
"0cd15956d4": "Ocultar {{value0}} espacios de trabajo secundarios",
|
||||
"bd37a57ac8": "Create workspace for {{value0}}",
|
||||
"b667b59632": "Some projects could not be removed from Orca",
|
||||
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
|
||||
"groupDeleteFailed": "Failed to delete group",
|
||||
|
|
@ -3795,6 +3807,15 @@
|
|||
},
|
||||
"index": {
|
||||
"b826a98b6f": "ocupado"
|
||||
},
|
||||
"FolderWorkspaceComposerDialog": {
|
||||
"connectFailed": "Failed to connect to project.",
|
||||
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
|
||||
"title": "Create Folder Workspace",
|
||||
"createStart": "Create & Start Agent",
|
||||
"create": "Create Workspace",
|
||||
"sourceProject": "Task Source",
|
||||
"chooseSourceProject": "Choose task source"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
|
|
|||
|
|
@ -3055,7 +3055,17 @@
|
|||
"8db50afe1a": "フォルダーからリポジトリをインポートする",
|
||||
"5b2e6fe3c8": "個別にインポート",
|
||||
"cf9d382ca1": "輸入",
|
||||
"220dd32d83": "走査..."
|
||||
"220dd32d83": "走査...",
|
||||
"fb33359f69": "これはモノレポですか?",
|
||||
"d75170194e": "これらのプロジェクトがまとまっている場合は、これを選択してください。Orca がグループ化し、親フォルダーから作業できるようにします。",
|
||||
"39d51212cc": "モノレポ名",
|
||||
"e907ec8935": "モノレポ名とは何ですか?",
|
||||
"aa0247680d": "いいえ、個別にインポート",
|
||||
"a0bc4d1f8e": "はい、モノレポとしてインポート",
|
||||
"8401a7a0d0": "1 個のリポジトリ",
|
||||
"d4f1df62ef": "{{value0}} 個のリポジトリ",
|
||||
"b4263a2ac4": "{{value1}} で {{value0}} が見つかりました。",
|
||||
"24eda6c8b2": "スキャン中... {{value0}}"
|
||||
},
|
||||
"AddRepoRemoteStep": {
|
||||
"5b205b5281": "スキャンの停止",
|
||||
|
|
@ -3535,7 +3545,8 @@
|
|||
"f50603c6b2": "未読としてマークする",
|
||||
"8dacff1fe0": "既読マークを付ける",
|
||||
"3baa7d6507": "ピン",
|
||||
"697d0f6e1b": "固定を解除する"
|
||||
"697d0f6e1b": "固定を解除する",
|
||||
"250de158fd": "Remove Workspace"
|
||||
},
|
||||
"WorktreeList": {
|
||||
"d880ea0744": "グループを作成し、このプロジェクトをそのグループに移動します。",
|
||||
|
|
@ -3569,6 +3580,7 @@
|
|||
"c1f4a31623": "{{value0}} 個の子ワークスペースを表示",
|
||||
"e97297cb75": "{{value0}} 個の子ワークスペースを非表示",
|
||||
"0cd15956d4": "{{value0}} 個の子ワークスペースを非表示",
|
||||
"bd37a57ac8": "Create workspace for {{value0}}",
|
||||
"b667b59632": "Some projects could not be removed from Orca",
|
||||
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
|
||||
"groupDeleteFailed": "Failed to delete group",
|
||||
|
|
@ -3795,6 +3807,15 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"FolderWorkspaceComposerDialog": {
|
||||
"connectFailed": "Failed to connect to project.",
|
||||
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
|
||||
"title": "Create Folder Workspace",
|
||||
"createStart": "Create & Start Agent",
|
||||
"create": "Create Workspace",
|
||||
"sourceProject": "Task Source",
|
||||
"chooseSourceProject": "Choose task source"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
|
|
|||
|
|
@ -3055,7 +3055,17 @@
|
|||
"8db50afe1a": "폴더에서 저장소 가져오기",
|
||||
"5b2e6fe3c8": "별도로 가져오기",
|
||||
"cf9d382ca1": "수입",
|
||||
"220dd32d83": "스캐닝..."
|
||||
"220dd32d83": "스캐닝...",
|
||||
"fb33359f69": "이 폴더가 모노레포인가요?",
|
||||
"d75170194e": "이 프로젝트들이 함께 속한다면 이것을 선택하세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.",
|
||||
"39d51212cc": "모노레포 이름",
|
||||
"e907ec8935": "모노레포 이름이란 무엇인가요?",
|
||||
"aa0247680d": "아니요, 별도로 가져오기",
|
||||
"a0bc4d1f8e": "예, 모노레포로 가져오기",
|
||||
"8401a7a0d0": "저장소 1개",
|
||||
"d4f1df62ef": "저장소 {{value0}}개",
|
||||
"b4263a2ac4": "{{value1}}에서 {{value0}}을(를) 찾았습니다.",
|
||||
"24eda6c8b2": "스캔 중... {{value0}}"
|
||||
},
|
||||
"AddRepoRemoteStep": {
|
||||
"5b205b5281": "스캔 중지",
|
||||
|
|
@ -3535,7 +3545,8 @@
|
|||
"f50603c6b2": "읽지 않은 것으로 표시",
|
||||
"8dacff1fe0": "마크 리드",
|
||||
"3baa7d6507": "핀",
|
||||
"697d0f6e1b": "고정 해제"
|
||||
"697d0f6e1b": "고정 해제",
|
||||
"250de158fd": "Remove Workspace"
|
||||
},
|
||||
"WorktreeList": {
|
||||
"d880ea0744": "그룹을 만들고 이 프로젝트를 그룹으로 이동하세요.",
|
||||
|
|
@ -3569,6 +3580,7 @@
|
|||
"c1f4a31623": "{{value0}}개 하위 워크스페이스 표시",
|
||||
"e97297cb75": "{{value0}}개 하위 워크스페이스 숨기기",
|
||||
"0cd15956d4": "{{value0}}개 하위 워크스페이스 숨기기",
|
||||
"bd37a57ac8": "Create workspace for {{value0}}",
|
||||
"b667b59632": "Some projects could not be removed from Orca",
|
||||
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
|
||||
"groupDeleteFailed": "Failed to delete group",
|
||||
|
|
@ -3795,6 +3807,15 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"FolderWorkspaceComposerDialog": {
|
||||
"connectFailed": "Failed to connect to project.",
|
||||
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
|
||||
"title": "Create Folder Workspace",
|
||||
"createStart": "Create & Start Agent",
|
||||
"create": "Create Workspace",
|
||||
"sourceProject": "Task Source",
|
||||
"chooseSourceProject": "Choose task source"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
|
|
|||
|
|
@ -3055,7 +3055,17 @@
|
|||
"8db50afe1a": "从文件夹导入存储库",
|
||||
"5b2e6fe3c8": "单独导入",
|
||||
"cf9d382ca1": "导入",
|
||||
"220dd32d83": "扫描..."
|
||||
"220dd32d83": "扫描...",
|
||||
"fb33359f69": "这是 Monorepo 吗?",
|
||||
"d75170194e": "如果这些项目属于同一组,请选择此项。Orca 会将它们分组,并让你从父文件夹开始工作。",
|
||||
"39d51212cc": "Monorepo 名称",
|
||||
"e907ec8935": "什么是 Monorepo 名称?",
|
||||
"aa0247680d": "否,单独导入",
|
||||
"a0bc4d1f8e": "是,作为 Monorepo 导入",
|
||||
"8401a7a0d0": "1 个仓库",
|
||||
"d4f1df62ef": "{{value0}} 个仓库",
|
||||
"b4263a2ac4": "在 {{value1}} 中找到 {{value0}}。",
|
||||
"24eda6c8b2": "正在扫描... {{value0}}"
|
||||
},
|
||||
"AddRepoRemoteStep": {
|
||||
"5b205b5281": "停止扫描",
|
||||
|
|
@ -3535,7 +3545,8 @@
|
|||
"f50603c6b2": "标记为未读",
|
||||
"8dacff1fe0": "马克·里德",
|
||||
"3baa7d6507": "别针",
|
||||
"697d0f6e1b": "取消固定"
|
||||
"697d0f6e1b": "取消固定",
|
||||
"250de158fd": "Remove Workspace"
|
||||
},
|
||||
"WorktreeList": {
|
||||
"d880ea0744": "创建一个组并将该项目移入其中。",
|
||||
|
|
@ -3569,6 +3580,7 @@
|
|||
"c1f4a31623": "显示 {{value0}} 个子工作区",
|
||||
"e97297cb75": "隐藏 {{value0}} 个子工作区",
|
||||
"0cd15956d4": "隐藏 {{value0}} 个子工作区",
|
||||
"bd37a57ac8": "Create workspace for {{value0}}",
|
||||
"b667b59632": "Some projects could not be removed from Orca",
|
||||
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
|
||||
"groupDeleteFailed": "Failed to delete group",
|
||||
|
|
@ -3795,6 +3807,15 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"FolderWorkspaceComposerDialog": {
|
||||
"connectFailed": "Failed to connect to project.",
|
||||
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
|
||||
"title": "Create Folder Workspace",
|
||||
"createStart": "Create & Start Agent",
|
||||
"create": "Create Workspace",
|
||||
"sourceProject": "Task Source",
|
||||
"chooseSourceProject": "Choose task source"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|||
import type { Repo } from '../../../shared/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getConnectionId } from './connection-context'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
|
||||
|
|
@ -51,4 +52,262 @@ describe('getConnectionId', () => {
|
|||
|
||||
expect(getConnectionId('repo-missing::/tmp/repo-feature')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves SSH targets for folder workspaces from repos in the folder scope', () => {
|
||||
useAppStore.setState({
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'Platform workspace',
|
||||
folderPath: '/home/neil/platform',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
name: 'Platform',
|
||||
parentPath: '/home/neil/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
repos: [
|
||||
makeRepo({
|
||||
id: 'repo-ssh',
|
||||
path: '/home/neil/platform/api',
|
||||
projectGroupId: 'group-1',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
],
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
|
||||
expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBe('ssh-1')
|
||||
})
|
||||
|
||||
it('resolves SSH targets for repo-less folder workspaces from persisted scope provenance', () => {
|
||||
useAppStore.setState({
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'Platform workspace',
|
||||
folderPath: '/home/neil/platform',
|
||||
connectionId: 'ssh-1',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
name: 'Platform',
|
||||
parentPath: '/home/neil/platform',
|
||||
connectionId: 'ssh-1',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
repos: [],
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
|
||||
expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBe('ssh-1')
|
||||
})
|
||||
|
||||
it('returns undefined when persisted folder workspace provenance conflicts with child repos', () => {
|
||||
useAppStore.setState({
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'Platform workspace',
|
||||
folderPath: '/home/neil/platform',
|
||||
connectionId: 'ssh-1',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
name: 'Platform',
|
||||
parentPath: '/home/neil/platform',
|
||||
connectionId: 'ssh-1',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
repos: [
|
||||
makeRepo({
|
||||
id: 'repo-ssh',
|
||||
path: '/home/neil/platform/api',
|
||||
projectGroupId: 'group-1',
|
||||
connectionId: 'ssh-2'
|
||||
})
|
||||
],
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
|
||||
expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for folder workspaces with mixed local and SSH repos', () => {
|
||||
useAppStore.setState({
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'Platform workspace',
|
||||
folderPath: '/home/neil/platform',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
name: 'Platform',
|
||||
parentPath: '/home/neil/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
repos: [
|
||||
makeRepo({
|
||||
id: 'repo-local',
|
||||
path: '/home/neil/platform/web',
|
||||
projectGroupId: 'group-1'
|
||||
}),
|
||||
makeRepo({
|
||||
id: 'repo-ssh',
|
||||
path: '/home/neil/platform/api',
|
||||
projectGroupId: 'group-1',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
],
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
|
||||
expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps explicit folder workspace provenance isolated from unrelated same-path SSH repos', () => {
|
||||
useAppStore.setState({
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'Platform workspace',
|
||||
folderPath: '/home/neil/platform',
|
||||
connectionId: 'ssh-1',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
name: 'Platform',
|
||||
parentPath: '/home/neil/platform',
|
||||
connectionId: 'ssh-1',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'group-2',
|
||||
name: 'Platform copy',
|
||||
parentPath: '/home/neil/platform',
|
||||
connectionId: 'ssh-2',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 1,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
repos: [
|
||||
makeRepo({
|
||||
id: 'repo-ssh-1',
|
||||
path: '/home/neil/platform/api',
|
||||
projectGroupId: 'group-1',
|
||||
connectionId: 'ssh-1'
|
||||
}),
|
||||
makeRepo({
|
||||
id: 'repo-ssh-2',
|
||||
path: '/home/neil/platform/api',
|
||||
projectGroupId: 'group-2',
|
||||
connectionId: 'ssh-2'
|
||||
})
|
||||
],
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
|
||||
expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBe('ssh-1')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useAppStore } from '@/store'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { getFolderWorkspaceConnectionId } from './folder-workspace-connection'
|
||||
|
||||
/**
|
||||
* Resolve the SSH connectionId for a worktree. Returns null for local repos,
|
||||
|
|
@ -10,6 +12,13 @@ export function getConnectionId(worktreeId: string | null): string | null | unde
|
|||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type === 'folder') {
|
||||
return getFolderWorkspaceConnectionId(
|
||||
useAppStore.getState(),
|
||||
parsedWorkspaceKey.folderWorkspaceId
|
||||
)
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const allWorktrees = Object.values(state.worktreesByRepo ?? {}).flat()
|
||||
const worktree = allWorktrees.find((w) => w.id === worktreeId)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import type { FolderWorkspace, ProjectGroup, Repo } from '../../../shared/types'
|
||||
import { isPathInsideOrEqual } from '../../../shared/cross-platform-path'
|
||||
import { getProjectGroupSubtreeIds } from '../../../shared/project-groups'
|
||||
|
||||
export type FolderWorkspaceConnectionState = {
|
||||
folderWorkspaces: FolderWorkspace[]
|
||||
projectGroups: ProjectGroup[]
|
||||
repos: Repo[]
|
||||
}
|
||||
|
||||
function getFolderScopeCandidateRepos(args: {
|
||||
folderPath: string
|
||||
projectGroupId: string
|
||||
connectionId?: string | null
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
repos: readonly Repo[]
|
||||
}): Repo[] {
|
||||
const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
|
||||
const groupRepos = args.repos.filter(
|
||||
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
|
||||
)
|
||||
const pathRepos = args.repos.filter(
|
||||
(repo) =>
|
||||
!(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) &&
|
||||
isPathInsideOrEqual(args.folderPath, repo.path)
|
||||
)
|
||||
if (args.connectionId) {
|
||||
return [
|
||||
...groupRepos,
|
||||
...pathRepos.filter((repo) => (repo.connectionId ?? null) === args.connectionId)
|
||||
]
|
||||
}
|
||||
if (groupRepos.length === 0) {
|
||||
return pathRepos
|
||||
}
|
||||
const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null))
|
||||
return [
|
||||
...groupRepos,
|
||||
...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null))
|
||||
]
|
||||
}
|
||||
|
||||
export function getFolderWorkspaceConnectionId(
|
||||
state: FolderWorkspaceConnectionState,
|
||||
folderWorkspaceId: string
|
||||
): string | null | undefined {
|
||||
const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId)
|
||||
if (!workspace) {
|
||||
return undefined
|
||||
}
|
||||
const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId)
|
||||
const scopeConnectionId = workspace.connectionId ?? group?.connectionId ?? null
|
||||
|
||||
const candidateRepos = getFolderScopeCandidateRepos({
|
||||
folderPath: workspace.folderPath,
|
||||
projectGroupId: workspace.projectGroupId,
|
||||
connectionId: scopeConnectionId,
|
||||
projectGroups: state.projectGroups,
|
||||
repos: state.repos
|
||||
})
|
||||
let hasLocalRepo = false
|
||||
const connectionIds = new Set<string>()
|
||||
for (const repo of candidateRepos) {
|
||||
if (repo.connectionId) {
|
||||
connectionIds.add(repo.connectionId)
|
||||
} else {
|
||||
hasLocalRepo = true
|
||||
}
|
||||
}
|
||||
if (scopeConnectionId) {
|
||||
const hasDifferentSshConnection = [...connectionIds].some(
|
||||
(connectionId) => connectionId !== scopeConnectionId
|
||||
)
|
||||
if (hasLocalRepo || hasDifferentSshConnection) {
|
||||
return undefined
|
||||
}
|
||||
return scopeConnectionId
|
||||
}
|
||||
if (candidateRepos.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (hasLocalRepo && connectionIds.size > 0) {
|
||||
return undefined
|
||||
}
|
||||
if (connectionIds.size === 0) {
|
||||
return null
|
||||
}
|
||||
if (connectionIds.size === 1) {
|
||||
return [...connectionIds][0]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { FOLDER_WORKSPACE_PATH_STATUS_TTL_MS } from '../../../shared/folder-workspace-path-status'
|
||||
|
||||
type FolderWorkspacePathStatusCacheClockEntry = {
|
||||
checkedAt: number
|
||||
}
|
||||
|
||||
export function useFolderWorkspacePathStatusCacheExpiryTick(
|
||||
entries: Record<string, FolderWorkspacePathStatusCacheClockEntry>
|
||||
): number {
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now()
|
||||
let nextDelayMs = Number.POSITIVE_INFINITY
|
||||
for (const entry of Object.values(entries)) {
|
||||
const delayMs = entry.checkedAt + FOLDER_WORKSPACE_PATH_STATUS_TTL_MS - now
|
||||
if (delayMs > 0) {
|
||||
nextDelayMs = Math.min(nextDelayMs, delayMs)
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(nextDelayMs)) {
|
||||
return
|
||||
}
|
||||
// Why: TTL freshness is derived from Date.now(), so subscribers need one
|
||||
// clock tick when the oldest cached status stops being authoritative.
|
||||
const timeout = window.setTimeout(() => setTick((value) => value + 1), nextDelayMs + 1)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [entries, tick])
|
||||
|
||||
return tick
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import type { FolderWorkspacePathStatus } from '../../../shared/folder-workspace-path-status'
|
||||
import { blocksFolderWorkspaceActivation } from '../../../shared/folder-workspace-path-status'
|
||||
|
||||
export function getFolderWorkspacePathStatusTitle(
|
||||
status: FolderWorkspacePathStatus | null | undefined
|
||||
): string | null {
|
||||
if (!status || status.exists) {
|
||||
return null
|
||||
}
|
||||
switch (status.reason) {
|
||||
case 'missing':
|
||||
return 'Folder not found'
|
||||
case 'not-directory':
|
||||
return 'Path is not a folder'
|
||||
case 'ambiguous-connection':
|
||||
return 'Cannot determine connection'
|
||||
case 'unavailable':
|
||||
default:
|
||||
return 'Cannot check folder'
|
||||
}
|
||||
}
|
||||
|
||||
export function getFolderWorkspacePathStatusDescription(
|
||||
status: FolderWorkspacePathStatus | null | undefined
|
||||
): string | null {
|
||||
if (!status || status.exists) {
|
||||
return null
|
||||
}
|
||||
switch (status.reason) {
|
||||
case 'missing':
|
||||
return `Orca cannot find ${status.path}. Remove and re-import this folder workspace.`
|
||||
case 'not-directory':
|
||||
return `${status.path} exists, but it is not a folder.`
|
||||
case 'ambiguous-connection':
|
||||
return 'Orca cannot tell which SSH connection owns this folder scope.'
|
||||
case 'unavailable':
|
||||
default:
|
||||
return 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFolderWorkspaceCreateError(error: unknown): {
|
||||
title: string
|
||||
description: string
|
||||
} {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const path = message.includes(':') ? message.slice(message.indexOf(':') + 1) : ''
|
||||
if (message.startsWith('folder_workspace_path_missing:')) {
|
||||
return {
|
||||
title: 'Folder not found',
|
||||
description: `Orca cannot find ${path}. Remove and re-import the folder.`
|
||||
}
|
||||
}
|
||||
if (message.startsWith('folder_workspace_path_not_directory:')) {
|
||||
return {
|
||||
title: 'Path is not a folder',
|
||||
description: `${path} exists, but it is not a folder.`
|
||||
}
|
||||
}
|
||||
if (message.startsWith('folder_workspace_connection_ambiguous:')) {
|
||||
return {
|
||||
title: 'Cannot determine connection',
|
||||
description: 'Orca cannot tell which SSH connection owns this folder scope.'
|
||||
}
|
||||
}
|
||||
if (message.startsWith('folder_workspace_path_unavailable:')) {
|
||||
return {
|
||||
title: 'Cannot check folder',
|
||||
description:
|
||||
'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.'
|
||||
}
|
||||
}
|
||||
return { title: 'Failed to create folder workspace', description: message }
|
||||
}
|
||||
|
||||
export function folderWorkspaceActivationBlocked(
|
||||
status: FolderWorkspacePathStatus | null | undefined
|
||||
): boolean {
|
||||
return blocksFolderWorkspaceActivation(status)
|
||||
}
|
||||
|
|
@ -6,7 +6,8 @@ import {
|
|||
} from '@/runtime/runtime-terminal-inspection'
|
||||
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { isShellProcess } from '@/lib/tui-agent-startup'
|
||||
import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types'
|
||||
import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context'
|
||||
import type { FolderWorkspaceLinkedTask, OrcaHooks, TaskViewPresetId } from '../../../shared/types'
|
||||
import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy'
|
||||
import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition'
|
||||
import { slugifyForWorkspaceName } from '../../../shared/workspace-name'
|
||||
|
|
@ -48,16 +49,11 @@ export const CLIENT_PLATFORM: NodeJS.Platform = navigator.userAgent.includes('Wi
|
|||
|
||||
export { getLinkedWorkItemProvider, isGitLabIssueUrl } from './linked-work-item-provider'
|
||||
|
||||
export type LinkedWorkItemSummary = {
|
||||
type: 'issue' | 'pr' | 'mr'
|
||||
provider?: 'github' | 'gitlab' | 'linear' | 'jira'
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
linearIdentifier?: string
|
||||
export type LinkedWorkItemSummary = Omit<FolderWorkspaceLinkedTask, 'provider'> & {
|
||||
provider?: FolderWorkspaceLinkedTask['provider']
|
||||
linearWorkspaceId?: string
|
||||
linearOrganizationUrlKey?: string
|
||||
jiraIdentifier?: string
|
||||
linkedContext?: LinkedWorkItemContext
|
||||
}
|
||||
|
||||
// Why: when a repo has no `orca.yaml` issueCommand and no per-user override,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => {
|
|||
openFiles: [] as { worktreeId: string }[]
|
||||
}
|
||||
const activateAndRevealWorktree = vi.fn()
|
||||
const activateAndRevealFolderWorkspace = vi.fn()
|
||||
const markInputQuietSchedulerInput = vi.fn()
|
||||
const pendingCallbacks: (() => void)[] = []
|
||||
const pendingCancels: ReturnType<typeof vi.fn>[] = []
|
||||
|
|
@ -26,6 +27,7 @@ const mocks = vi.hoisted(() => {
|
|||
})
|
||||
return {
|
||||
activateAndRevealWorktree,
|
||||
activateAndRevealFolderWorkspace,
|
||||
markInputQuietSchedulerInput,
|
||||
pendingCallbacks,
|
||||
pendingCancels,
|
||||
|
|
@ -41,6 +43,7 @@ vi.mock('@/store', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace,
|
||||
activateAndRevealWorktree: mocks.activateAndRevealWorktree
|
||||
}))
|
||||
|
||||
|
|
@ -59,6 +62,7 @@ describe('sidebar worktree activation', () => {
|
|||
delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__
|
||||
cancelPendingSidebarWorktreeActivation()
|
||||
mocks.activateAndRevealWorktree.mockClear()
|
||||
mocks.activateAndRevealFolderWorkspace.mockClear()
|
||||
mocks.markInputQuietSchedulerInput.mockClear()
|
||||
mocks.scheduleAfterInputQuiet.mockClear()
|
||||
mocks.pendingCallbacks.length = 0
|
||||
|
|
@ -91,6 +95,14 @@ describe('sidebar worktree activation', () => {
|
|||
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-live')
|
||||
})
|
||||
|
||||
it('routes folder workspace activation through the guarded folder path', () => {
|
||||
activateWorktreeFromSidebar('folder:folder-workspace-1')
|
||||
|
||||
expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1')
|
||||
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
expect(mocks.scheduleAfterInputQuiet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not defer slept workspace activation in the web client', () => {
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
mocks.state.tabsByWorktree = { 'wt-web-slept': [{ id: 'tab-1' }] }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { useAppStore } from '@/store'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import {
|
||||
activateAndRevealFolderWorkspace,
|
||||
activateAndRevealWorktree
|
||||
} from '@/lib/worktree-activation'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
|
||||
const SLEPT_WORKTREE_ACTIVATION_INPUT_QUIET_MS = 450
|
||||
const SLEPT_WORKTREE_ACTIVATION_IDLE_TIMEOUT_MS = 120
|
||||
|
|
@ -37,6 +41,11 @@ function shouldDeferSidebarWorktreeActivation(worktreeId: string): boolean {
|
|||
|
||||
export function activateWorktreeFromSidebar(worktreeId: string): void {
|
||||
cancelPendingSidebarWorktreeActivation()
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId)
|
||||
return
|
||||
}
|
||||
|
||||
const activate = (): void => {
|
||||
if (pendingSidebarWorktreeActivation?.worktreeId === worktreeId) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { buildWorkspaceSessionPayload, type WorkspaceSessionSnapshot } from './w
|
|||
function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSessionSnapshot {
|
||||
return {
|
||||
activeRepoId: null,
|
||||
activeWorkspaceKey: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ function createSnapshot(
|
|||
): WorkspaceSessionSnapshot {
|
||||
return {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorkspaceKey: 'worktree:wt-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabsByWorktree: {},
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ function createSnapshot(
|
|||
): WorkspaceSessionSnapshot {
|
||||
return {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorkspaceKey: 'worktree:wt-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabsByWorktree: {},
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ describe('SESSION_RELEVANT_FIELDS', () => {
|
|||
// A snapshot field omitted here would persist stale data after that field changes.
|
||||
const fixture: Record<keyof WorkspaceSessionSnapshot, true> = {
|
||||
activeRepoId: true,
|
||||
activeWorkspaceKey: true,
|
||||
activeWorktreeId: true,
|
||||
activeTabId: true,
|
||||
tabsByWorktree: true,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export function shouldPersistWorkspaceSession(
|
|||
export type WorkspaceSessionSnapshot = Pick<
|
||||
AppState,
|
||||
| 'activeRepoId'
|
||||
| 'activeWorkspaceKey'
|
||||
| 'activeWorktreeId'
|
||||
| 'activeTabId'
|
||||
| 'tabsByWorktree'
|
||||
|
|
@ -70,6 +71,7 @@ export type WorkspaceSessionSnapshot = Pick<
|
|||
// time, preventing the gate from silently going stale.
|
||||
export const SESSION_RELEVANT_FIELDS = [
|
||||
'activeRepoId',
|
||||
'activeWorkspaceKey',
|
||||
'activeWorktreeId',
|
||||
'activeTabId',
|
||||
'tabsByWorktree',
|
||||
|
|
@ -333,6 +335,7 @@ export function buildWorkspaceSessionPayload(
|
|||
|
||||
const payload = {
|
||||
activeRepoId: snapshot.activeRepoId,
|
||||
activeWorkspaceKey: snapshot.activeWorkspaceKey,
|
||||
activeWorktreeId: snapshot.activeWorktreeId,
|
||||
activeTabId: snapshot.activeTabId,
|
||||
tabsByWorktree: buildSanitizedTabsByWorktree(snapshot.tabsByWorktree),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/* eslint-disable max-lines -- Why: worktree activation is a single ordered flow spanning startup, setup, issue commands, and default tabs; splitting it would obscure sequencing guarantees. */
|
||||
import type {
|
||||
FolderWorkspace,
|
||||
SetupSplitDirection,
|
||||
TuiAgent,
|
||||
Worktree,
|
||||
|
|
@ -37,6 +38,13 @@ import {
|
|||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import { isTuiAgent } from '../../../shared/tui-agent-config'
|
||||
import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
folderWorkspaceActivationBlocked,
|
||||
getFolderWorkspacePathStatusDescription,
|
||||
getFolderWorkspacePathStatusTitle
|
||||
} from './folder-workspace-path-status'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/** Telemetry payload threaded from the launch site to `pty:spawn`. Main
|
||||
* fires `agent_started` only after the spawn succeeds — see
|
||||
|
|
@ -121,6 +129,71 @@ export type ActivateAndRevealResult = {
|
|||
primaryTabId: string | null
|
||||
}
|
||||
|
||||
function ensureFolderWorkspaceInitialTerminal(
|
||||
folderWorkspace: FolderWorkspace,
|
||||
startup?: WorktreeStartupPayload
|
||||
): string | null {
|
||||
const state = useAppStore.getState()
|
||||
const workspaceKey = folderWorkspaceKey(folderWorkspace.id)
|
||||
const primaryTabId = ensureWorktreeHasInitialTerminal(
|
||||
state,
|
||||
workspaceKey,
|
||||
startup,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
return primaryTabId
|
||||
}
|
||||
|
||||
export function activateAndRevealFolderWorkspace(
|
||||
folderWorkspaceId: string,
|
||||
opts?: {
|
||||
sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior']
|
||||
startup?: WorktreeStartupPayload
|
||||
}
|
||||
): ActivateAndRevealResult | false {
|
||||
const state = useAppStore.getState()
|
||||
const folderWorkspace = state.folderWorkspaces.find(
|
||||
(workspace) => workspace.id === folderWorkspaceId
|
||||
)
|
||||
if (!folderWorkspace) {
|
||||
return false
|
||||
}
|
||||
const pathStatus = state.getFreshFolderWorkspacePathStatus({
|
||||
scope: 'folder-workspace',
|
||||
folderWorkspaceId
|
||||
})
|
||||
if (folderWorkspaceActivationBlocked(pathStatus)) {
|
||||
toast.error(getFolderWorkspacePathStatusTitle(pathStatus) ?? 'Cannot open folder workspace', {
|
||||
description: getFolderWorkspacePathStatusDescription(pathStatus) ?? folderWorkspace.folderPath
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (state.activeView !== 'terminal') {
|
||||
state.setActiveView('terminal')
|
||||
}
|
||||
|
||||
state.setActiveFolderWorkspace(folderWorkspaceId)
|
||||
|
||||
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
|
||||
state.markWorktreeVisited(workspaceKey)
|
||||
if (!state.isNavigatingHistory) {
|
||||
state.recordWorktreeVisit(workspaceKey)
|
||||
}
|
||||
resumeSleepingAgentSessionsForWorktree(workspaceKey)
|
||||
const primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup)
|
||||
|
||||
if (opts?.sidebarRevealBehavior) {
|
||||
state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior })
|
||||
} else {
|
||||
state.revealWorktreeInSidebar(workspaceKey)
|
||||
}
|
||||
|
||||
return { primaryTabId }
|
||||
}
|
||||
|
||||
function buildCreatedAgentReopenStartup(worktree: Worktree): WorktreeStartupPayload | undefined {
|
||||
const agent = worktree.createdWithAgent
|
||||
if (!isTuiAgent(agent)) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
} from '../../../../shared/types'
|
||||
import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID, ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { redactKagiSessionToken } from '../../../../shared/browser-url'
|
||||
import {
|
||||
MAX_BROWSER_HISTORY_ENTRIES,
|
||||
|
|
@ -1412,6 +1413,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
.map((worktree) => worktree.id)
|
||||
)
|
||||
validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
for (const workspace of currentState.folderWorkspaces) {
|
||||
validWorktreeIdsForCleanup.add(folderWorkspaceKey(workspace.id))
|
||||
}
|
||||
|
||||
// Why: mirror closeBrowserTab's contract — reducers are pure, imperative
|
||||
// side effects bracket them. Compute dropped workspaces first, destroy
|
||||
|
|
@ -1441,6 +1445,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
.map((worktree) => worktree.id)
|
||||
)
|
||||
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
for (const workspace of s.folderWorkspaces) {
|
||||
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
|
||||
}
|
||||
|
||||
const browserTabsByWorktree: Record<string, BrowserWorkspace[]> = {}
|
||||
const browserPagesByWorkspace: Record<string, BrowserPage[]> = {}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import type {
|
|||
} from '../../../../shared/types'
|
||||
import { stripCredentialsFromMessage } from '../../../../shared/git-remote-error'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action'
|
||||
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
|
||||
import {
|
||||
|
|
@ -3873,6 +3874,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
.map((w) => w.id)
|
||||
)
|
||||
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
for (const workspace of s.folderWorkspaces) {
|
||||
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
|
||||
}
|
||||
|
||||
const openFiles: OpenFile[] = []
|
||||
const editorDrafts: Record<string, string> = {}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
import type { NestedRepoScanResult, Repo, ProjectGroup } from '../../../../shared/types'
|
||||
import type {
|
||||
NestedRepoScanResult,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
FolderWorkspace
|
||||
} from '../../../../shared/types'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
|
||||
const remoteRepo: Repo = {
|
||||
id: 'remote-repo',
|
||||
|
|
@ -39,9 +46,23 @@ const projectGroupsImportNested = vi.fn()
|
|||
const projectGroupsScanNested = vi.fn()
|
||||
const projectGroupsCancelNestedScan = vi.fn()
|
||||
const projectGroupsOnNestedScanProgress = vi.fn()
|
||||
const folderWorkspacesList = vi.fn()
|
||||
const folderWorkspacesGetPathStatus = vi.fn()
|
||||
const folderWorkspacesCreate = vi.fn()
|
||||
const folderWorkspacesUpdate = vi.fn()
|
||||
const folderWorkspacesDelete = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
|
||||
function makeSshConnectionState(status: SshConnectionState['status']): SshConnectionState {
|
||||
return {
|
||||
targetId: 'ssh-1',
|
||||
status,
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
reposList.mockReset()
|
||||
|
|
@ -57,6 +78,12 @@ beforeEach(() => {
|
|||
projectGroupsCancelNestedScan.mockReset()
|
||||
projectGroupsOnNestedScanProgress.mockReset()
|
||||
projectGroupsOnNestedScanProgress.mockReturnValue(vi.fn())
|
||||
folderWorkspacesList.mockReset()
|
||||
folderWorkspacesGetPathStatus.mockReset()
|
||||
folderWorkspacesGetPathStatus.mockResolvedValue({ path: '/workspace/platform', exists: true })
|
||||
folderWorkspacesCreate.mockReset()
|
||||
folderWorkspacesUpdate.mockReset()
|
||||
folderWorkspacesDelete.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
|
|
@ -79,6 +106,13 @@ beforeEach(() => {
|
|||
onNestedScanProgress: projectGroupsOnNestedScanProgress,
|
||||
importNested: projectGroupsImportNested
|
||||
},
|
||||
folderWorkspaces: {
|
||||
list: folderWorkspacesList,
|
||||
getPathStatus: folderWorkspacesGetPathStatus,
|
||||
create: folderWorkspacesCreate,
|
||||
update: folderWorkspacesUpdate,
|
||||
delete: folderWorkspacesDelete
|
||||
},
|
||||
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
|
||||
}
|
||||
})
|
||||
|
|
@ -99,6 +133,358 @@ describe('project group store routing', () => {
|
|||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates, updates, and deletes local folder workspaces', async () => {
|
||||
const linkedTask: FolderWorkspace['linkedTask'] = {
|
||||
provider: 'linear',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
title: 'Refund fix',
|
||||
url: 'https://linear.app/acme/issue/ENG-123',
|
||||
linearIdentifier: 'ENG-123'
|
||||
}
|
||||
const folderWorkspace: FolderWorkspace = {
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: projectGroup.id,
|
||||
name: 'Refund fix',
|
||||
folderPath: '/workspace/platform',
|
||||
linkedTask,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
folderWorkspacesCreate.mockResolvedValue(folderWorkspace)
|
||||
folderWorkspacesUpdate.mockResolvedValue({ ...folderWorkspace, comment: 'Ready' })
|
||||
folderWorkspacesDelete.mockResolvedValue(true)
|
||||
const store = createTestStore()
|
||||
|
||||
await expect(
|
||||
store.getState().createFolderWorkspace({
|
||||
projectGroupId: projectGroup.id,
|
||||
name: 'Refund fix',
|
||||
linkedTask
|
||||
})
|
||||
).resolves.toEqual(folderWorkspace)
|
||||
await expect(
|
||||
store.getState().updateFolderWorkspace(folderWorkspace.id, { comment: 'Ready' })
|
||||
).resolves.toBe(true)
|
||||
await expect(store.getState().deleteFolderWorkspace(folderWorkspace.id)).resolves.toBe(true)
|
||||
|
||||
expect(folderWorkspacesCreate).toHaveBeenCalledWith({
|
||||
projectGroupId: projectGroup.id,
|
||||
name: 'Refund fix',
|
||||
linkedTask
|
||||
})
|
||||
expect(folderWorkspacesUpdate).toHaveBeenCalledWith({
|
||||
folderWorkspaceId: folderWorkspace.id,
|
||||
updates: { comment: 'Ready' }
|
||||
})
|
||||
expect(folderWorkspacesDelete).toHaveBeenCalledWith({
|
||||
folderWorkspaceId: folderWorkspace.id
|
||||
})
|
||||
expect(store.getState().folderWorkspaces).toEqual([])
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caches local folder workspace path status by scope', async () => {
|
||||
const folderGroup = { ...projectGroup, parentPath: '/workspace/platform' }
|
||||
folderWorkspacesGetPathStatus.mockResolvedValue({
|
||||
path: '/workspace/platform',
|
||||
exists: false,
|
||||
reason: 'missing'
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({ projectGroups: [folderGroup] })
|
||||
|
||||
await expect(
|
||||
store.getState().fetchFolderWorkspacePathStatus({
|
||||
scope: 'project-group',
|
||||
projectGroupId: folderGroup.id
|
||||
})
|
||||
).resolves.toEqual({
|
||||
path: '/workspace/platform',
|
||||
exists: false,
|
||||
reason: 'missing'
|
||||
})
|
||||
|
||||
const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey({
|
||||
scope: 'project-group',
|
||||
projectGroupId: folderGroup.id
|
||||
})
|
||||
expect(store.getState().folderWorkspacePathStatuses[cacheKey]?.status).toEqual({
|
||||
path: '/workspace/platform',
|
||||
exists: false,
|
||||
reason: 'missing'
|
||||
})
|
||||
expect(folderWorkspacesGetPathStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores stale folder path status responses after a group path changes', async () => {
|
||||
let resolveStatus: (status: { path: string; exists: boolean }) => void = () => {}
|
||||
folderWorkspacesGetPathStatus.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveStatus = resolve
|
||||
})
|
||||
)
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
projectGroups: [{ ...projectGroup, parentPath: '/workspace/old-platform' }]
|
||||
})
|
||||
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
|
||||
const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request)
|
||||
|
||||
store.setState({
|
||||
projectGroups: [{ ...projectGroup, parentPath: '/workspace/new-platform' }]
|
||||
})
|
||||
resolveStatus({ path: '/workspace/old-platform', exists: true })
|
||||
await statusPromise
|
||||
|
||||
const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request)
|
||||
expect(store.getState().folderWorkspacePathStatuses[cacheKey]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores stale folder path status responses after repo ownership changes', async () => {
|
||||
let resolveStatus: (status: { path: string; exists: boolean }) => void = () => {}
|
||||
folderWorkspacesGetPathStatus.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveStatus = resolve
|
||||
})
|
||||
)
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
projectGroups: [{ ...projectGroup, parentPath: '/workspace/platform' }],
|
||||
repos: [{ ...remoteRepo, id: 'local-repo', path: '/workspace/platform/api' }]
|
||||
})
|
||||
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
|
||||
const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request)
|
||||
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
...remoteRepo,
|
||||
id: 'ssh-repo',
|
||||
path: '/workspace/platform/api',
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
resolveStatus({ path: '/workspace/platform', exists: true })
|
||||
await statusPromise
|
||||
|
||||
const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request)
|
||||
expect(store.getState().folderWorkspacePathStatuses[cacheKey]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats expired folder path status cache entries as unknown', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
projectGroups: [{ ...projectGroup, parentPath: '/workspace/platform' }]
|
||||
})
|
||||
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
|
||||
await store.getState().fetchFolderWorkspacePathStatus(request)
|
||||
|
||||
expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toEqual({
|
||||
path: '/workspace/platform',
|
||||
exists: true
|
||||
})
|
||||
|
||||
vi.setSystemTime(Date.now() + 10_001)
|
||||
|
||||
expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats current-state mismatched folder path cache entries as unknown', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
projectGroups: [
|
||||
{ ...projectGroup, parentPath: '/workspace/platform', connectionId: 'ssh-1' }
|
||||
],
|
||||
sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('connected')]])
|
||||
})
|
||||
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
|
||||
await store.getState().fetchFolderWorkspacePathStatus(request)
|
||||
|
||||
expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toEqual({
|
||||
path: '/workspace/platform',
|
||||
exists: true
|
||||
})
|
||||
|
||||
store.setState({
|
||||
sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('disconnected')]])
|
||||
})
|
||||
|
||||
expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores stale folder path status responses after SSH connection state changes', async () => {
|
||||
const resolvers: ((status: { path: string; exists: boolean; reason?: string }) => void)[] = []
|
||||
folderWorkspacesGetPathStatus.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvers.push(resolve)
|
||||
})
|
||||
)
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
projectGroups: [
|
||||
{ ...projectGroup, parentPath: '/workspace/platform', connectionId: 'ssh-1' }
|
||||
],
|
||||
sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('connected')]])
|
||||
})
|
||||
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
|
||||
const connectedStatusPromise = store.getState().fetchFolderWorkspacePathStatus(request)
|
||||
|
||||
store.setState({
|
||||
sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('disconnected')]])
|
||||
})
|
||||
const disconnectedStatusPromise = store
|
||||
.getState()
|
||||
.fetchFolderWorkspacePathStatus(request, { force: true })
|
||||
|
||||
resolvers[1]?.({
|
||||
path: '/workspace/platform',
|
||||
exists: false,
|
||||
reason: 'unavailable'
|
||||
})
|
||||
await disconnectedStatusPromise
|
||||
resolvers[0]?.({ path: '/workspace/platform', exists: true })
|
||||
await connectedStatusPromise
|
||||
|
||||
const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request)
|
||||
expect(store.getState().folderWorkspacePathStatuses[cacheKey]?.status).toEqual({
|
||||
path: '/workspace/platform',
|
||||
exists: false,
|
||||
reason: 'unavailable'
|
||||
})
|
||||
})
|
||||
|
||||
it('purges renderer session state when deleting a local folder workspace', async () => {
|
||||
const folderWorkspace: FolderWorkspace = {
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: projectGroup.id,
|
||||
name: 'Refund fix',
|
||||
folderPath: '/workspace/platform',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const workspaceKey = folderWorkspaceKey(folderWorkspace.id)
|
||||
folderWorkspacesDelete.mockResolvedValue(true)
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
folderWorkspaces: [folderWorkspace],
|
||||
activeWorktreeId: workspaceKey,
|
||||
activeWorkspaceKey: workspaceKey,
|
||||
activeTabId: 'terminal-tab-1',
|
||||
activeBrowserTabId: 'browser-tab-1',
|
||||
activeTabType: 'browser',
|
||||
tabsByWorktree: {
|
||||
[workspaceKey]: [
|
||||
{
|
||||
id: 'terminal-tab-1',
|
||||
worktreeId: workspaceKey,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'terminal-tab-1': {
|
||||
root: { type: 'leaf', leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
[workspaceKey]: [
|
||||
{
|
||||
id: 'browser-tab-1',
|
||||
worktreeId: workspaceKey,
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'browser-tab-1': [
|
||||
{
|
||||
id: 'page-1',
|
||||
workspaceId: 'browser-tab-1',
|
||||
worktreeId: workspaceKey,
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
openFiles: [
|
||||
{
|
||||
id: 'file-1',
|
||||
worktreeId: workspaceKey,
|
||||
filePath: '/workspace/platform/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
language: 'markdown',
|
||||
isDirty: true,
|
||||
isPreview: false,
|
||||
mode: 'edit'
|
||||
}
|
||||
],
|
||||
editorDrafts: { 'file-1': 'draft' },
|
||||
activeFileIdByWorktree: { [workspaceKey]: 'file-1' },
|
||||
activeTabTypeByWorktree: { [workspaceKey]: 'browser' },
|
||||
activeBrowserTabIdByWorktree: { [workspaceKey]: 'browser-tab-1' },
|
||||
lastVisitedAtByWorktreeId: { [workspaceKey]: 10 }
|
||||
})
|
||||
|
||||
await expect(store.getState().deleteFolderWorkspace(folderWorkspace.id)).resolves.toBe(true)
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.folderWorkspaces).toEqual([])
|
||||
expect(state.activeWorktreeId).toBeNull()
|
||||
expect(state.activeWorkspaceKey).toBeNull()
|
||||
expect(state.tabsByWorktree[workspaceKey]).toBeUndefined()
|
||||
expect(state.terminalLayoutsByTabId['terminal-tab-1']).toBeUndefined()
|
||||
expect(state.browserTabsByWorktree[workspaceKey]).toBeUndefined()
|
||||
expect(state.browserPagesByWorkspace['browser-tab-1']).toBeUndefined()
|
||||
expect(state.openFiles).toEqual([])
|
||||
expect(state.editorDrafts).toEqual({})
|
||||
expect(state.activeFileIdByWorktree[workspaceKey]).toBeUndefined()
|
||||
expect(state.activeBrowserTabIdByWorktree[workspaceKey]).toBeUndefined()
|
||||
expect(state.lastVisitedAtByWorktreeId[workspaceKey]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refreshes local repos and groups after importing nested repos', async () => {
|
||||
const importedRepo: Repo = {
|
||||
...remoteRepo,
|
||||
|
|
@ -116,6 +502,7 @@ describe('project group store routing', () => {
|
|||
}
|
||||
projectGroupsImportNested.mockResolvedValue(result)
|
||||
projectGroupsList.mockResolvedValue([projectGroup])
|
||||
folderWorkspacesList.mockResolvedValue([])
|
||||
reposList.mockResolvedValue([importedRepo])
|
||||
const store = createTestStore()
|
||||
|
||||
|
|
@ -135,6 +522,7 @@ describe('project group store routing', () => {
|
|||
mode: 'group'
|
||||
})
|
||||
expect(projectGroupsList).toHaveBeenCalled()
|
||||
expect(folderWorkspacesList).toHaveBeenCalled()
|
||||
expect(reposList).toHaveBeenCalled()
|
||||
expect(store.getState().projectGroups).toEqual([projectGroup])
|
||||
expect(store.getState().repos).toEqual([importedRepo])
|
||||
|
|
@ -286,10 +674,26 @@ describe('project group store routing', () => {
|
|||
name: 'Tools',
|
||||
tabOrder: 1
|
||||
}
|
||||
const childWorkspace: FolderWorkspace = {
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: childGroup.id,
|
||||
name: 'Shared cleanup',
|
||||
folderPath: '/workspace/platform/shared',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
projectGroupsDelete.mockResolvedValue(true)
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
projectGroups: [projectGroup, childGroup, siblingGroup],
|
||||
folderWorkspaces: [childWorkspace],
|
||||
repos: [
|
||||
{ ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id },
|
||||
{ ...remoteRepo, id: 'nested', projectGroupId: childGroup.id },
|
||||
|
|
@ -300,6 +704,7 @@ describe('project group store routing', () => {
|
|||
await expect(store.getState().deleteProjectGroup(projectGroup.id)).resolves.toBe(true)
|
||||
|
||||
expect(store.getState().projectGroups.map((group) => group.id)).toEqual([siblingGroup.id])
|
||||
expect(store.getState().folderWorkspaces).toEqual([])
|
||||
expect(store.getState().repos).toMatchObject([
|
||||
{ id: 'direct', projectGroupId: null },
|
||||
{ id: 'nested', projectGroupId: null },
|
||||
|
|
|
|||
|
|
@ -8,22 +8,31 @@ import type { AppState } from '../types'
|
|||
import type {
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
FolderWorkspace,
|
||||
ProjectGroupImportResult,
|
||||
NestedRepoScanResult
|
||||
} from '../../../../shared/types'
|
||||
import {
|
||||
FOLDER_WORKSPACE_PATH_STATUS_TTL_MS,
|
||||
type FolderWorkspacePathStatus,
|
||||
type FolderWorkspacePathStatusRequest
|
||||
} from '../../../../shared/folder-workspace-path-status'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
|
||||
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
|
||||
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
|
||||
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
|
||||
import { selectProjectGroupRemovalTargets } from './project-group-removal-targets'
|
||||
import { getRepoIdFromWorktreeId } from './worktree-helpers'
|
||||
import { reconcileFetchedRepos } from './repo-identity-reconcile'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
|
||||
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
|
||||
import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup'
|
||||
import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist'
|
||||
import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { buildDismissedOnboardingFolderAgentStartup } from '../../lib/onboarding-folder-agent-startup'
|
||||
import { markOnboardingProjectAdded } from '../../lib/onboarding-project-checklist'
|
||||
import { filterSetupScriptPromptDismissalsToValidRepos } from '../../lib/setup-script-prompt'
|
||||
import { translate } from '../../i18n/i18n'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status'
|
||||
|
||||
const ERROR_TOAST_DURATION = 60_000
|
||||
|
||||
|
|
@ -52,6 +61,12 @@ type NestedRepoScanControls = {
|
|||
onProgress?: (scan: NestedRepoScanResult) => void
|
||||
}
|
||||
|
||||
export type FolderWorkspacePathStatusCacheEntry = {
|
||||
status: FolderWorkspacePathStatus
|
||||
checkedAt: number
|
||||
requestSnapshot: string
|
||||
}
|
||||
|
||||
export type DeleteProjectGroupWithContainedProjectsOptions = {
|
||||
removeContainedProjects: boolean
|
||||
}
|
||||
|
|
@ -133,12 +148,122 @@ function getKnownRepoWorktreeIds(state: AppState, projectId: string): string[] {
|
|||
return [...ids]
|
||||
}
|
||||
|
||||
function getFolderWorkspacePathStatusScopeKey(request: FolderWorkspacePathStatusRequest): string {
|
||||
return request.scope === 'project-group'
|
||||
? `project-group:${request.projectGroupId}`
|
||||
: `folder-workspace:${request.folderWorkspaceId}`
|
||||
}
|
||||
|
||||
function getRuntimeTargetCachePrefix(state: AppState): string {
|
||||
const target = getActiveRuntimeTarget(state.settings)
|
||||
return target.kind === 'local' ? 'local' : `environment:${target.environmentId}`
|
||||
}
|
||||
|
||||
function getFolderWorkspaceStatusRequestSnapshot(
|
||||
state: Pick<AppState, 'projectGroups' | 'folderWorkspaces' | 'repos' | 'sshConnectionStates'>,
|
||||
request: FolderWorkspacePathStatusRequest
|
||||
): string | null {
|
||||
const scope =
|
||||
request.scope === 'project-group'
|
||||
? state.projectGroups.find((group) => group.id === request.projectGroupId)
|
||||
: state.folderWorkspaces.find((workspace) => workspace.id === request.folderWorkspaceId)
|
||||
const projectGroup =
|
||||
request.scope === 'project-group'
|
||||
? scope && 'parentPath' in scope
|
||||
? scope
|
||||
: null
|
||||
: scope && 'projectGroupId' in scope
|
||||
? state.projectGroups.find((group) => group.id === scope.projectGroupId)
|
||||
: null
|
||||
const folderPath =
|
||||
request.scope === 'project-group'
|
||||
? scope && 'parentPath' in scope
|
||||
? scope.parentPath
|
||||
: null
|
||||
: scope && 'folderPath' in scope
|
||||
? scope.folderPath
|
||||
: null
|
||||
const projectGroupId =
|
||||
request.scope === 'project-group'
|
||||
? request.projectGroupId
|
||||
: scope && 'projectGroupId' in scope
|
||||
? scope.projectGroupId
|
||||
: null
|
||||
const scopeConnectionId =
|
||||
request.scope === 'project-group'
|
||||
? scope && 'parentPath' in scope
|
||||
? scope.connectionId
|
||||
: null
|
||||
: scope && 'folderPath' in scope
|
||||
? (scope.connectionId ?? projectGroup?.connectionId)
|
||||
: null
|
||||
if (!folderPath || !projectGroupId) {
|
||||
return null
|
||||
}
|
||||
const groupIds = getProjectGroupSubtreeIds(state.projectGroups, projectGroupId)
|
||||
const candidateRepos = state.repos.filter(
|
||||
(repo) =>
|
||||
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
|
||||
isPathInsideOrEqual(folderPath, repo.path)
|
||||
)
|
||||
const relevantConnectionIds = new Set<string>()
|
||||
if (scopeConnectionId) {
|
||||
relevantConnectionIds.add(scopeConnectionId)
|
||||
}
|
||||
for (const repo of candidateRepos) {
|
||||
if (repo.connectionId) {
|
||||
relevantConnectionIds.add(repo.connectionId)
|
||||
}
|
||||
}
|
||||
const sshFingerprint = [...relevantConnectionIds]
|
||||
.map(
|
||||
(connectionId) =>
|
||||
`${connectionId}:${state.sshConnectionStates.get(connectionId)?.status ?? 'missing'}`
|
||||
)
|
||||
.sort()
|
||||
.join('|')
|
||||
const repoFingerprint = candidateRepos
|
||||
.map(
|
||||
(repo) => `${repo.id}:${repo.path}:${repo.projectGroupId ?? ''}:${repo.connectionId ?? ''}`
|
||||
)
|
||||
.sort()
|
||||
.join('|')
|
||||
return [
|
||||
folderPath,
|
||||
projectGroupId,
|
||||
scopeConnectionId ?? '',
|
||||
sshFingerprint,
|
||||
repoFingerprint
|
||||
].join('\0')
|
||||
}
|
||||
|
||||
function getFreshFolderWorkspacePathStatusFromCache(args: {
|
||||
entry: FolderWorkspacePathStatusCacheEntry | undefined
|
||||
requestSnapshot: string | null
|
||||
}): FolderWorkspacePathStatus | null {
|
||||
const { entry, requestSnapshot } = args
|
||||
if (!entry || requestSnapshot === null || entry.requestSnapshot !== requestSnapshot) {
|
||||
return null
|
||||
}
|
||||
return Date.now() - entry.checkedAt < FOLDER_WORKSPACE_PATH_STATUS_TTL_MS ? entry.status : null
|
||||
}
|
||||
|
||||
function getFolderWorkspacePathStatusRequestSnapshotForRead(
|
||||
state: AppState,
|
||||
request: FolderWorkspacePathStatusRequest
|
||||
): string | null {
|
||||
return getFolderWorkspaceStatusRequestSnapshot(state, request)
|
||||
}
|
||||
|
||||
export type RepoSlice = {
|
||||
repos: Repo[]
|
||||
projectGroups: ProjectGroup[]
|
||||
folderWorkspaces: FolderWorkspace[]
|
||||
folderWorkspacePathStatuses: Record<string, FolderWorkspacePathStatusCacheEntry>
|
||||
activeRepoId: string | null
|
||||
fetchRepos: () => Promise<void>
|
||||
fetchProjectGroups: () => Promise<void>
|
||||
fetchFolderWorkspaces: () => Promise<void>
|
||||
addRepo: () => Promise<Repo | null>
|
||||
addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise<Repo | null>
|
||||
addNonGitFolder: (path: string) => Promise<Repo | null>
|
||||
|
|
@ -157,6 +282,46 @@ export type RepoSlice = {
|
|||
mode: 'group' | 'separate'
|
||||
}) => Promise<ProjectGroupImportResult | null>
|
||||
createProjectGroup: (name: string) => Promise<ProjectGroup | null>
|
||||
createFolderWorkspace: (args: {
|
||||
projectGroupId: string
|
||||
name?: string
|
||||
folderPath?: string | null
|
||||
connectionId?: string | null
|
||||
linkedTask?: FolderWorkspace['linkedTask']
|
||||
createdWithAgent?: FolderWorkspace['createdWithAgent']
|
||||
pendingFirstAgentMessageRename?: boolean
|
||||
}) => Promise<FolderWorkspace | null>
|
||||
getFolderWorkspacePathStatusCacheKey: (request: FolderWorkspacePathStatusRequest) => string
|
||||
getFreshFolderWorkspacePathStatus: (
|
||||
request: FolderWorkspacePathStatusRequest
|
||||
) => FolderWorkspacePathStatus | null
|
||||
fetchFolderWorkspacePathStatus: (
|
||||
request: FolderWorkspacePathStatusRequest,
|
||||
options?: { force?: boolean }
|
||||
) => Promise<FolderWorkspacePathStatus | null>
|
||||
updateFolderWorkspace: (
|
||||
folderWorkspaceId: string,
|
||||
updates: Partial<
|
||||
Pick<
|
||||
FolderWorkspace,
|
||||
| 'name'
|
||||
| 'folderPath'
|
||||
| 'linkedTask'
|
||||
| 'comment'
|
||||
| 'isArchived'
|
||||
| 'isUnread'
|
||||
| 'isPinned'
|
||||
| 'sortOrder'
|
||||
| 'manualOrder'
|
||||
| 'workspaceStatus'
|
||||
| 'createdWithAgent'
|
||||
| 'pendingFirstAgentMessageRename'
|
||||
| 'firstAgentMessageRenameError'
|
||||
| 'lastActivityAt'
|
||||
>
|
||||
>
|
||||
) => Promise<boolean>
|
||||
deleteFolderWorkspace: (folderWorkspaceId: string) => Promise<boolean>
|
||||
updateProjectGroup: (
|
||||
groupId: string,
|
||||
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
|
||||
|
|
@ -180,6 +345,8 @@ export type RepoSlice = {
|
|||
export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, get) => ({
|
||||
repos: [],
|
||||
projectGroups: [],
|
||||
folderWorkspaces: [],
|
||||
folderWorkspacePathStatuses: {},
|
||||
activeRepoId: null,
|
||||
|
||||
fetchRepos: async () => {
|
||||
|
|
@ -204,6 +371,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
const reconciledRepos = reconcileFetchedRepos(s.repos, repos)
|
||||
return {
|
||||
repos: reconciledRepos,
|
||||
folderWorkspacePathStatuses: {},
|
||||
activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null,
|
||||
filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)),
|
||||
setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos(
|
||||
|
|
@ -233,12 +401,84 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
}
|
||||
)
|
||||
).groups
|
||||
set({ projectGroups })
|
||||
set({ projectGroups, folderWorkspacePathStatuses: {} })
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch project groups:', err)
|
||||
}
|
||||
},
|
||||
|
||||
fetchFolderWorkspaces: async () => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const folderWorkspaces =
|
||||
target.kind === 'local'
|
||||
? await window.api.folderWorkspaces.list()
|
||||
: (
|
||||
await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>(
|
||||
target,
|
||||
'folderWorkspace.list',
|
||||
undefined,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).folderWorkspaces
|
||||
set({ folderWorkspaces, folderWorkspacePathStatuses: {} })
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch folder workspaces:', err)
|
||||
}
|
||||
},
|
||||
|
||||
getFolderWorkspacePathStatusCacheKey: (request) =>
|
||||
`${getRuntimeTargetCachePrefix(get())}:${getFolderWorkspacePathStatusScopeKey(request)}`,
|
||||
|
||||
getFreshFolderWorkspacePathStatus: (request) => {
|
||||
const state = get()
|
||||
const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request)
|
||||
const cached = state.folderWorkspacePathStatuses[cacheKey]
|
||||
const requestSnapshot = getFolderWorkspacePathStatusRequestSnapshotForRead(state, request)
|
||||
return getFreshFolderWorkspacePathStatusFromCache({ entry: cached, requestSnapshot })
|
||||
},
|
||||
|
||||
fetchFolderWorkspacePathStatus: async (request, options) => {
|
||||
const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request)
|
||||
const requestSnapshot = getFolderWorkspaceStatusRequestSnapshot(get(), request)
|
||||
const cached = get().folderWorkspacePathStatuses[cacheKey]
|
||||
const freshCachedStatus = getFreshFolderWorkspacePathStatusFromCache({
|
||||
entry: cached,
|
||||
requestSnapshot
|
||||
})
|
||||
if (!options?.force && freshCachedStatus) {
|
||||
return freshCachedStatus
|
||||
}
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const status =
|
||||
target.kind === 'local'
|
||||
? await window.api.folderWorkspaces.getPathStatus(request)
|
||||
: (
|
||||
await callRuntimeRpc<{ status: FolderWorkspacePathStatus }>(
|
||||
target,
|
||||
'folderWorkspace.getPathStatus',
|
||||
request,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).status
|
||||
set((state) => ({
|
||||
folderWorkspacePathStatuses:
|
||||
requestSnapshot !== null &&
|
||||
getFolderWorkspaceStatusRequestSnapshot(state, request) === requestSnapshot
|
||||
? {
|
||||
...state.folderWorkspacePathStatuses,
|
||||
[cacheKey]: { status, checkedAt: Date.now(), requestSnapshot }
|
||||
}
|
||||
: state.folderWorkspacePathStatuses
|
||||
}))
|
||||
return status
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch folder workspace path status:', err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
scanNestedRepos: async (path, connectionId, controls) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
|
|
@ -311,7 +551,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
await get().fetchProjectGroups()
|
||||
await get().fetchFolderWorkspaces()
|
||||
await get().fetchRepos()
|
||||
set({ folderWorkspacePathStatuses: {} })
|
||||
return result
|
||||
} catch (err) {
|
||||
console.error('Failed to import nested repos:', err)
|
||||
|
|
@ -342,7 +584,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).group
|
||||
set((s) => ({ projectGroups: [...s.projectGroups, group] }))
|
||||
set((s) => ({ projectGroups: [...s.projectGroups, group], folderWorkspacePathStatuses: {} }))
|
||||
return group
|
||||
} catch (err) {
|
||||
console.error('Failed to create project group:', err)
|
||||
|
|
@ -350,6 +592,95 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
}
|
||||
},
|
||||
|
||||
createFolderWorkspace: async (args) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const workspace =
|
||||
target.kind === 'local'
|
||||
? await window.api.folderWorkspaces.create(args)
|
||||
: (
|
||||
await callRuntimeRpc<{ folderWorkspace: FolderWorkspace }>(
|
||||
target,
|
||||
'folderWorkspace.create',
|
||||
args,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).folderWorkspace
|
||||
set((s) => ({
|
||||
folderWorkspaces: [workspace, ...s.folderWorkspaces],
|
||||
folderWorkspacePathStatuses: {}
|
||||
}))
|
||||
return workspace
|
||||
} catch (err) {
|
||||
console.error('Failed to create folder workspace:', err)
|
||||
const { title, description } = formatFolderWorkspaceCreateError(err)
|
||||
toast.error(title, { description, duration: ERROR_TOAST_DURATION })
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
updateFolderWorkspace: async (folderWorkspaceId, updates) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const updated =
|
||||
target.kind === 'local'
|
||||
? await window.api.folderWorkspaces.update({ folderWorkspaceId, updates })
|
||||
: (
|
||||
await callRuntimeRpc<{ folderWorkspace: FolderWorkspace | null }>(
|
||||
target,
|
||||
'folderWorkspace.update',
|
||||
{ folderWorkspaceId, updates },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).folderWorkspace
|
||||
if (!updated) {
|
||||
return false
|
||||
}
|
||||
set((s) => ({
|
||||
folderWorkspaces: s.folderWorkspaces.map((workspace) =>
|
||||
workspace.id === folderWorkspaceId ? updated : workspace
|
||||
),
|
||||
folderWorkspacePathStatuses: {}
|
||||
}))
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to update folder workspace:', err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
deleteFolderWorkspace: async (folderWorkspaceId) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const deleted =
|
||||
target.kind === 'local'
|
||||
? await window.api.folderWorkspaces.delete({ folderWorkspaceId })
|
||||
: (
|
||||
await callRuntimeRpc<{ deleted: boolean }>(
|
||||
target,
|
||||
'folderWorkspace.delete',
|
||||
{ folderWorkspaceId },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).deleted
|
||||
if (!deleted) {
|
||||
return false
|
||||
}
|
||||
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
|
||||
set((s) => ({
|
||||
folderWorkspaces: s.folderWorkspaces.filter(
|
||||
(workspace) => workspace.id !== folderWorkspaceId
|
||||
),
|
||||
folderWorkspacePathStatuses: {}
|
||||
}))
|
||||
get().purgeWorktreeTerminalState([workspaceKey])
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to delete folder workspace:', err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectGroup: async (groupId, updates) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
|
|
@ -368,7 +699,8 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
return false
|
||||
}
|
||||
set((s) => ({
|
||||
projectGroups: s.projectGroups.map((group) => (group.id === groupId ? updated : group))
|
||||
projectGroups: s.projectGroups.map((group) => (group.id === groupId ? updated : group)),
|
||||
folderWorkspacePathStatuses: {}
|
||||
}))
|
||||
return true
|
||||
} catch (err) {
|
||||
|
|
@ -398,11 +730,15 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
const deletedGroupIds = getProjectGroupSubtreeIds(s.projectGroups, groupId)
|
||||
return {
|
||||
projectGroups: s.projectGroups.filter((group) => !deletedGroupIds.has(group.id)),
|
||||
folderWorkspaces: s.folderWorkspaces.filter(
|
||||
(workspace) => !deletedGroupIds.has(workspace.projectGroupId)
|
||||
),
|
||||
repos: s.repos.map((repo) =>
|
||||
repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)
|
||||
? { ...repo, projectGroupId: null }
|
||||
: repo
|
||||
)
|
||||
),
|
||||
folderWorkspacePathStatuses: {}
|
||||
}
|
||||
})
|
||||
return true
|
||||
|
|
@ -498,7 +834,10 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
if (!moved) {
|
||||
return false
|
||||
}
|
||||
set((s) => ({ repos: s.repos.map((repo) => (repo.id === projectId ? moved : repo)) }))
|
||||
set((s) => ({
|
||||
repos: s.repos.map((repo) => (repo.id === projectId ? moved : repo)),
|
||||
folderWorkspacePathStatuses: {}
|
||||
}))
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to move repo to group:', err)
|
||||
|
|
@ -548,7 +887,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
if (s.repos.some((r) => r.id === repo.id)) {
|
||||
return s
|
||||
}
|
||||
return { repos: [...s.repos, repo] }
|
||||
return { repos: [...s.repos, repo], folderWorkspacePathStatuses: {} }
|
||||
})
|
||||
if (alreadyAdded) {
|
||||
toast.info(translate('auto.store.slices.repos.a8e4b3af5b', 'Project already added'), {
|
||||
|
|
@ -751,6 +1090,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
activeFileId: activeFileCleared ? null : s.activeFileId,
|
||||
activeTabType: activeFileCleared ? 'terminal' : s.activeTabType,
|
||||
lastVisitedAtByWorktreeId: nextLastVisitedAtByWorktreeId,
|
||||
folderWorkspacePathStatuses: {},
|
||||
sortEpoch: s.sortEpoch + 1,
|
||||
// Why: removing the last repo while in settings leaves activeView as
|
||||
// 'settings', which renders an empty settings pane instead of Landing.
|
||||
|
|
@ -760,6 +1100,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
? {
|
||||
activeView: 'terminal' as const,
|
||||
activeWorktreeId: null,
|
||||
activeWorkspaceKey: null,
|
||||
activeRepoId: null
|
||||
}
|
||||
: {})
|
||||
|
|
@ -807,7 +1148,8 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
...updatesWithoutSourceControlAi,
|
||||
...(sourceControlAi !== undefined ? { sourceControlAi } : {})
|
||||
}
|
||||
})
|
||||
}),
|
||||
folderWorkspacePathStatuses: {}
|
||||
}))
|
||||
return true
|
||||
} catch (err) {
|
||||
|
|
@ -849,7 +1191,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
// Caller passed a non-permutation — refuse to apply locally.
|
||||
return
|
||||
}
|
||||
set({ repos: next })
|
||||
set({ repos: next, folderWorkspacePathStatuses: {} })
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const result =
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ function runtimeScopedStateReset(): Partial<AppState> {
|
|||
return {
|
||||
repos: [],
|
||||
projectGroups: [],
|
||||
folderWorkspaces: [],
|
||||
activeRepoId: null,
|
||||
sparsePresetsByRepo: {},
|
||||
sparsePresetsLoadingByRepo: {},
|
||||
|
|
@ -62,6 +63,7 @@ function runtimeScopedStateReset(): Partial<AppState> {
|
|||
detectedWorktreesByRepo: {},
|
||||
worktreeLineageById: {},
|
||||
activeWorktreeId: null,
|
||||
activeWorkspaceKey: null,
|
||||
deleteStateByWorktreeId: {},
|
||||
baseStatusByWorktreeId: {},
|
||||
remoteBranchConflictByWorktreeId: {},
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { buildHydratedTabState, pruneTabGroupLayoutForGroups } from './tabs-hydr
|
|||
import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
|
||||
export type TabSplitDirection = 'left' | 'right' | 'up' | 'down'
|
||||
|
||||
|
|
@ -855,6 +856,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
|||
...(shouldDeactivateWorktree
|
||||
? {
|
||||
activeWorktreeId: null,
|
||||
activeWorkspaceKey: null,
|
||||
activeTabId: null,
|
||||
activeBrowserTabId: null,
|
||||
activeFileId: null,
|
||||
|
|
@ -1818,6 +1820,9 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
|||
.map((w) => w.id)
|
||||
)
|
||||
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
for (const workspace of state.folderWorkspaces) {
|
||||
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
|
||||
}
|
||||
set(buildHydratedTabState(session, validWorktreeIds))
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,9 +8,15 @@ import type {
|
|||
TerminalTab,
|
||||
TuiAgent,
|
||||
Worktree,
|
||||
WorkspaceKey,
|
||||
WorkspaceSessionState
|
||||
} from '../../../../shared/types'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import {
|
||||
folderWorkspaceKey,
|
||||
parseWorkspaceKey,
|
||||
worktreeWorkspaceKey
|
||||
} from '../../../../shared/workspace-scope'
|
||||
import { deriveGeneratedTabTitle } from '../../../../shared/agent-tab-title'
|
||||
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
|
||||
|
|
@ -39,6 +45,7 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl
|
|||
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
||||
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection'
|
||||
import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
|
||||
import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
|
|
@ -171,9 +178,16 @@ function resolveCreatedTabShellOverride(
|
|||
}
|
||||
|
||||
function worktreeUsesWslPath(
|
||||
state: Pick<AppState, 'worktreesByRepo'>,
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
const parsed = parseWorkspaceKey(worktreeId)
|
||||
if (parsed?.type === 'folder') {
|
||||
const folderWorkspace = state.folderWorkspaces.find(
|
||||
(workspace) => workspace.id === parsed.folderWorkspaceId
|
||||
)
|
||||
return folderWorkspace ? isWslUncPath(folderWorkspace.folderPath) : false
|
||||
}
|
||||
const worktree = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((entry) => entry.id === worktreeId)
|
||||
|
|
@ -181,9 +195,13 @@ function worktreeUsesWslPath(
|
|||
}
|
||||
|
||||
export function worktreeUsesRemoteConnection(
|
||||
state: Pick<AppState, 'repos' | 'worktreesByRepo'>,
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type === 'folder') {
|
||||
return Boolean(getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId))
|
||||
}
|
||||
const directRepoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
const directRepo = state.repos.find((repo) => repo.id === directRepoId)
|
||||
if (directRepo) {
|
||||
|
|
@ -1968,7 +1986,14 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
// its tabs still use the normal terminal session pipeline so daemon PTYs
|
||||
// can survive app restart just like workspace terminals.
|
||||
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
for (const workspace of s.folderWorkspaces) {
|
||||
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
|
||||
}
|
||||
for (const worktreeId of Object.keys(session.tabsByWorktree)) {
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type === 'folder') {
|
||||
continue
|
||||
}
|
||||
if (!validWorktreeIds.has(worktreeId)) {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
// Why (#1158): an empty/missing list can mean degraded hydration; a
|
||||
|
|
@ -2041,6 +2066,14 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)
|
||||
? session.activeWorktreeId
|
||||
: null
|
||||
const activeWorkspaceKey: WorkspaceKey | null =
|
||||
session.activeWorkspaceKey && validWorktreeIds.has(session.activeWorkspaceKey)
|
||||
? session.activeWorkspaceKey
|
||||
: activeWorktreeId
|
||||
? parseWorkspaceKey(activeWorktreeId)
|
||||
? (activeWorktreeId as WorkspaceKey)
|
||||
: worktreeWorkspaceKey(activeWorktreeId)
|
||||
: null
|
||||
const activeTabId =
|
||||
session.activeTabId && validTabIds.has(session.activeTabId) ? session.activeTabId : null
|
||||
const activeRepoId =
|
||||
|
|
@ -2193,6 +2226,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
return {
|
||||
activeRepoId,
|
||||
activeWorktreeId,
|
||||
activeWorkspaceKey,
|
||||
activeTabId,
|
||||
activeTabIdByWorktree,
|
||||
tabsByWorktree,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import type {
|
|||
WorktreeBaseStatusEvent,
|
||||
WorktreeLineage,
|
||||
WorktreeRemoteBranchConflictEvent,
|
||||
WorktreeMeta
|
||||
WorktreeMeta,
|
||||
WorkspaceKey
|
||||
} from '../../../../shared/types'
|
||||
import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector'
|
||||
import type {
|
||||
|
|
@ -41,6 +42,7 @@ export type WorktreeSlice = {
|
|||
detectedWorktreesByRepo: Record<string, DetectedWorktreeListResult>
|
||||
worktreeLineageById: Record<string, WorktreeLineage>
|
||||
activeWorktreeId: string | null
|
||||
activeWorkspaceKey: WorkspaceKey | null
|
||||
/**
|
||||
* In-flight / failed background worktree creations, keyed by a renderer
|
||||
* `creationId`. Kept separate from `worktreesByRepo` on purpose — a real
|
||||
|
|
@ -209,6 +211,7 @@ export type WorktreeSlice = {
|
|||
*/
|
||||
seedActiveWorktreeLastVisitedIfMissing: () => void
|
||||
setActiveWorktree: (worktreeId: string | null) => void
|
||||
setActiveFolderWorkspace: (folderWorkspaceId: string) => void
|
||||
setRenamingWorktreeId: (worktreeId: string | null) => void
|
||||
allWorktrees: () => Worktree[]
|
||||
getKnownWorktreeById: (worktreeId: string) => Worktree | DetectedWorktree | undefined
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { create } from 'zustand'
|
|||
import type { AppState } from '../types'
|
||||
import type {
|
||||
DetectedWorktreeListResult,
|
||||
FolderWorkspace,
|
||||
LocalBaseRefRefreshResult,
|
||||
Worktree,
|
||||
WorktreeLineage
|
||||
|
|
@ -89,6 +90,7 @@ import {
|
|||
unregisterPersistentWebview
|
||||
} from '../../components/browser-pane/webview-registry'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
|
||||
function resetRemoteRuntimeMocks() {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
|
|
@ -200,6 +202,45 @@ function makeLineage(overrides: Partial<WorktreeLineage> = {}): WorktreeLineage
|
|||
}
|
||||
}
|
||||
|
||||
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
|
||||
return {
|
||||
...overrides,
|
||||
id: overrides.id ?? 'folder-1',
|
||||
projectGroupId: overrides.projectGroupId ?? 'group-1',
|
||||
name: overrides.name ?? 'platform workspace',
|
||||
folderPath: overrides.folderPath ?? '/work/platform',
|
||||
linkedTask: overrides.linkedTask ?? null,
|
||||
comment: overrides.comment ?? '',
|
||||
isArchived: overrides.isArchived ?? false,
|
||||
isUnread: overrides.isUnread ?? false,
|
||||
isPinned: overrides.isPinned ?? false,
|
||||
sortOrder: overrides.sortOrder ?? 0,
|
||||
manualOrder: overrides.manualOrder ?? 0,
|
||||
lastActivityAt: overrides.lastActivityAt ?? 0,
|
||||
createdAt: overrides.createdAt ?? 0,
|
||||
updatedAt: overrides.updatedAt ?? 0,
|
||||
workspaceStatus: overrides.workspaceStatus ?? 'active'
|
||||
}
|
||||
}
|
||||
|
||||
describe('folder workspace lookups', () => {
|
||||
it('returns a stable synthetic worktree for repeated folder workspace lookups', () => {
|
||||
const store = createTestStore()
|
||||
const folderWorkspace = makeFolderWorkspace()
|
||||
store.setState({ folderWorkspaces: [folderWorkspace] } as Partial<AppState>)
|
||||
|
||||
const first = store.getState().getKnownWorktreeById(folderWorkspaceKey(folderWorkspace.id))
|
||||
const second = store.getState().getKnownWorktreeById(folderWorkspaceKey(folderWorkspace.id))
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(first).toMatchObject({
|
||||
id: folderWorkspaceKey(folderWorkspace.id),
|
||||
displayName: folderWorkspace.name,
|
||||
path: folderWorkspace.folderPath
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('setActiveWorktree focus handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
TerminalPaneLayoutNode,
|
||||
LocalBaseRefRefreshResult,
|
||||
ForceDeleteWorktreeBranchResult,
|
||||
FolderWorkspace,
|
||||
GitHubPrStartPoint,
|
||||
Worktree,
|
||||
WorkspaceVisibleTabType,
|
||||
|
|
@ -40,6 +41,12 @@ import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/inp
|
|||
import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import {
|
||||
folderWorkspaceKey,
|
||||
parseWorkspaceKey,
|
||||
worktreeWorkspaceKey
|
||||
} from '../../../../shared/workspace-scope'
|
||||
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
|
||||
export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers'
|
||||
|
||||
// Why: old runtime servers only have `worktree.list`; preserve the large-list
|
||||
|
|
@ -50,6 +57,7 @@ const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450
|
|||
const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180
|
||||
const pendingActivationTerminalPrepCancels = new Map<string, () => void>()
|
||||
const detachedHeadAutoDerivedDisplayNames = new Map<string, string>()
|
||||
const folderWorkspaceWorktreeCache = new WeakMap<FolderWorkspace, Worktree>()
|
||||
|
||||
function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number {
|
||||
if (!node) {
|
||||
|
|
@ -387,9 +395,25 @@ function applyDetectedWorktreeUpdates(
|
|||
}
|
||||
|
||||
function findKnownWorktreeById(
|
||||
state: Pick<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo'>,
|
||||
state: Pick<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces'>,
|
||||
worktreeId: string
|
||||
): Worktree | DetectedWorktreeListResult['worktrees'][number] | undefined {
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
const folderWorkspace = state.folderWorkspaces.find(
|
||||
(workspace) => workspace.id === workspaceScope.folderWorkspaceId
|
||||
)
|
||||
if (!folderWorkspace) {
|
||||
return undefined
|
||||
}
|
||||
const cached = folderWorkspaceWorktreeCache.get(folderWorkspace)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const worktree = folderWorkspaceToWorktree(folderWorkspace)
|
||||
folderWorkspaceWorktreeCache.set(folderWorkspace, worktree)
|
||||
return worktree
|
||||
}
|
||||
const visible = findWorktreeById(state.worktreesByRepo, worktreeId)
|
||||
if (visible) {
|
||||
return visible
|
||||
|
|
@ -403,6 +427,84 @@ function findKnownWorktreeById(
|
|||
return undefined
|
||||
}
|
||||
|
||||
function getFolderWorkspaceMetaUpdates(
|
||||
updates: Partial<WorktreeMeta>
|
||||
): Partial<
|
||||
Pick<
|
||||
FolderWorkspace,
|
||||
| 'name'
|
||||
| 'comment'
|
||||
| 'isArchived'
|
||||
| 'isUnread'
|
||||
| 'isPinned'
|
||||
| 'sortOrder'
|
||||
| 'manualOrder'
|
||||
| 'lastActivityAt'
|
||||
| 'workspaceStatus'
|
||||
| 'createdWithAgent'
|
||||
| 'pendingFirstAgentMessageRename'
|
||||
| 'firstAgentMessageRenameError'
|
||||
>
|
||||
> {
|
||||
const next: Partial<
|
||||
Pick<
|
||||
FolderWorkspace,
|
||||
| 'name'
|
||||
| 'comment'
|
||||
| 'isArchived'
|
||||
| 'isUnread'
|
||||
| 'isPinned'
|
||||
| 'sortOrder'
|
||||
| 'manualOrder'
|
||||
| 'lastActivityAt'
|
||||
| 'workspaceStatus'
|
||||
| 'createdWithAgent'
|
||||
| 'pendingFirstAgentMessageRename'
|
||||
| 'firstAgentMessageRenameError'
|
||||
>
|
||||
> = {}
|
||||
if (updates.displayName !== undefined) {
|
||||
next.name = updates.displayName
|
||||
next.pendingFirstAgentMessageRename = false
|
||||
next.firstAgentMessageRenameError = null
|
||||
}
|
||||
if (updates.comment !== undefined) {
|
||||
next.comment = updates.comment
|
||||
next.lastActivityAt = Date.now()
|
||||
}
|
||||
if (updates.isArchived !== undefined) {
|
||||
next.isArchived = updates.isArchived
|
||||
}
|
||||
if (updates.isUnread !== undefined) {
|
||||
next.isUnread = updates.isUnread
|
||||
}
|
||||
if (updates.isPinned !== undefined) {
|
||||
next.isPinned = updates.isPinned
|
||||
}
|
||||
if (updates.sortOrder !== undefined) {
|
||||
next.sortOrder = updates.sortOrder
|
||||
}
|
||||
if (updates.manualOrder !== undefined) {
|
||||
next.manualOrder = updates.manualOrder
|
||||
}
|
||||
if (updates.lastActivityAt !== undefined) {
|
||||
next.lastActivityAt = updates.lastActivityAt
|
||||
}
|
||||
if (updates.workspaceStatus !== undefined) {
|
||||
next.workspaceStatus = updates.workspaceStatus
|
||||
}
|
||||
if (updates.createdWithAgent !== undefined) {
|
||||
next.createdWithAgent = updates.createdWithAgent
|
||||
}
|
||||
if (updates.pendingFirstAgentMessageRename !== undefined) {
|
||||
next.pendingFirstAgentMessageRename = updates.pendingFirstAgentMessageRename
|
||||
}
|
||||
if (updates.firstAgentMessageRenameError !== undefined) {
|
||||
next.firstAgentMessageRenameError = updates.firstAgentMessageRenameError
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function isRuntimeSelectorNotFoundError(error: unknown): boolean {
|
||||
if (
|
||||
error &&
|
||||
|
|
@ -585,11 +687,15 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
|
|||
|
||||
// Collect every tab id (and removed file id) we are about to orphan.
|
||||
const doomedTabIds = new Set<string>()
|
||||
const doomedBrowserWorkspaceIds = new Set<string>()
|
||||
const removedFileIds = new Set<string>()
|
||||
for (const id of worktreeIdSet) {
|
||||
for (const tab of s.tabsByWorktree[id] ?? []) {
|
||||
doomedTabIds.add(tab.id)
|
||||
}
|
||||
for (const workspace of s.browserTabsByWorktree[id] ?? []) {
|
||||
doomedBrowserWorkspaceIds.add(workspace.id)
|
||||
}
|
||||
}
|
||||
for (const file of s.openFiles) {
|
||||
if (worktreeIdSet.has(file.worktreeId)) {
|
||||
|
|
@ -641,6 +747,17 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
|
|||
}
|
||||
return changed ? out : obj
|
||||
}
|
||||
const omitByBrowserWorkspaceId = <T>(obj: Record<string, T>): Record<string, T> => {
|
||||
let changed = false
|
||||
const out = { ...obj }
|
||||
for (const workspaceId of doomedBrowserWorkspaceIds) {
|
||||
if (workspaceId in out) {
|
||||
delete out[workspaceId]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? out : obj
|
||||
}
|
||||
const omitByFileId = <T>(obj: Record<string, T>): Record<string, T> => {
|
||||
let changed = false
|
||||
const out = { ...obj }
|
||||
|
|
@ -694,6 +811,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
|
|||
fileSearchStateByWorktree: omitByWorktree(s.fileSearchStateByWorktree),
|
||||
// Browser state
|
||||
browserTabsByWorktree: omitByWorktree(s.browserTabsByWorktree),
|
||||
browserPagesByWorkspace: omitByBrowserWorkspaceId(s.browserPagesByWorkspace),
|
||||
recentlyClosedBrowserTabsByWorktree: omitByWorktree(s.recentlyClosedBrowserTabsByWorktree),
|
||||
activeBrowserTabIdByWorktree: omitByWorktree(s.activeBrowserTabIdByWorktree),
|
||||
// Editor state
|
||||
|
|
@ -728,6 +846,8 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
|
|||
everActivatedWorktreeIds: nextEverActivatedWorktreeIds,
|
||||
lastVisitedAtByWorktreeId: omitByWorktree(s.lastVisitedAtByWorktreeId),
|
||||
activeWorktreeId: removedActive ? null : s.activeWorktreeId,
|
||||
activeWorkspaceKey:
|
||||
s.activeWorkspaceKey && worktreeIdSet.has(s.activeWorkspaceKey) ? null : s.activeWorkspaceKey,
|
||||
activeFileId: activeFileCleared ? null : s.activeFileId,
|
||||
activeBrowserTabId: removedActive ? null : s.activeBrowserTabId,
|
||||
activeTabId: activeTabCleared ? null : s.activeTabId,
|
||||
|
|
@ -740,6 +860,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
detectedWorktreesByRepo: {},
|
||||
worktreeLineageById: {},
|
||||
activeWorktreeId: null,
|
||||
activeWorkspaceKey: null,
|
||||
pendingWorktreeCreations: {},
|
||||
activePendingCreationId: null,
|
||||
renamingWorktreeId: null,
|
||||
|
|
@ -1649,6 +1770,14 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
if (shouldApplyUpdate && !shouldApplyUpdate(existingWorktree)) {
|
||||
return
|
||||
}
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
const folderUpdates = getFolderWorkspaceMetaUpdates(updates)
|
||||
if (Object.keys(folderUpdates).length > 0) {
|
||||
await get().updateFolderWorkspace(workspaceScope.folderWorkspaceId, folderUpdates)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: manual PR linking only supplies the PR number. Resolve the PR head
|
||||
// branch here so Push targets the review branch, but don't repeat that
|
||||
// network lookup for no-op linkedPR metadata saves.
|
||||
|
|
@ -1863,7 +1992,12 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
if (!current || current.isPinned === isPinned) {
|
||||
continue
|
||||
}
|
||||
updates.set(worktreeId, { isPinned })
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
void get().updateWorktreeMeta(worktreeId, { isPinned })
|
||||
} else {
|
||||
updates.set(worktreeId, { isPinned })
|
||||
}
|
||||
if (revealWorktreeId === null) {
|
||||
revealWorktreeId = worktreeId
|
||||
}
|
||||
|
|
@ -2200,6 +2334,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
if (!worktreeId) {
|
||||
return {
|
||||
activeWorktreeId: null,
|
||||
activeWorkspaceKey: null,
|
||||
// Why: activating any real worktree (or clearing it) must dismiss the
|
||||
// background-creation panel so the user isn't stranded on it.
|
||||
activePendingCreationId: null
|
||||
|
|
@ -2420,6 +2555,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
|
||||
return {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceKey: worktreeWorkspaceKey(worktreeId),
|
||||
activePendingCreationId: null,
|
||||
activeFileId,
|
||||
activeBrowserTabId,
|
||||
|
|
@ -2512,6 +2648,115 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
}
|
||||
},
|
||||
|
||||
setActiveFolderWorkspace: (folderWorkspaceId) => {
|
||||
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
|
||||
const workspace = get().folderWorkspaces.find((entry) => entry.id === folderWorkspaceId)
|
||||
if (!workspace) {
|
||||
return
|
||||
}
|
||||
if (shouldDeferActivationTerminalPrep()) {
|
||||
markInputQuietSchedulerInput()
|
||||
}
|
||||
if (get().activeWorktreeId !== workspaceKey) {
|
||||
moveFocusToRendererBeforeFocusedWebviewHidden()
|
||||
}
|
||||
const reconciledActiveTabId =
|
||||
get().reconcileWorktreeTabModel(workspaceKey).activeRenderableTabId
|
||||
set((s) => {
|
||||
const restoredFileId = s.activeFileIdByWorktree[workspaceKey] ?? null
|
||||
const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[workspaceKey] ?? null
|
||||
const restoredTabType = s.activeTabTypeByWorktree[workspaceKey] ?? 'terminal'
|
||||
const activeGroupId =
|
||||
s.activeGroupIdByWorktree[workspaceKey] ?? s.groupsByWorktree[workspaceKey]?.[0]?.id ?? null
|
||||
const activeGroup = activeGroupId
|
||||
? ((s.groupsByWorktree[workspaceKey] ?? []).find((group) => group.id === activeGroupId) ??
|
||||
null)
|
||||
: null
|
||||
const activeUnifiedTabId = reconciledActiveTabId ?? activeGroup?.activeTabId ?? null
|
||||
const activeUnifiedTab =
|
||||
activeUnifiedTabId != null
|
||||
? ((s.unifiedTabsByWorktree[workspaceKey] ?? []).find(
|
||||
(tab) =>
|
||||
tab.id === activeUnifiedTabId && (!activeGroup || tab.groupId === activeGroup.id)
|
||||
) ?? null)
|
||||
: null
|
||||
const fileStillOpen = restoredFileId
|
||||
? s.openFiles.some((file) => file.id === restoredFileId && file.worktreeId === workspaceKey)
|
||||
: false
|
||||
const browserTabs = s.browserTabsByWorktree[workspaceKey] ?? []
|
||||
const browserTabStillOpen = restoredBrowserTabId
|
||||
? browserTabs.some((tab) => tab.id === restoredBrowserTabId)
|
||||
: false
|
||||
const worktreeTabs = s.tabsByWorktree[workspaceKey] ?? []
|
||||
const restoredTabId = s.activeTabIdByWorktree[workspaceKey] ?? null
|
||||
const tabStillExists = restoredTabId
|
||||
? worktreeTabs.some((tab) => tab.id === restoredTabId)
|
||||
: false
|
||||
const activeFileId =
|
||||
activeUnifiedTab?.contentType === 'editor' ||
|
||||
activeUnifiedTab?.contentType === 'diff' ||
|
||||
activeUnifiedTab?.contentType === 'conflict-review'
|
||||
? activeUnifiedTab.entityId
|
||||
: fileStillOpen
|
||||
? restoredFileId
|
||||
: null
|
||||
const activeBrowserTabId =
|
||||
activeUnifiedTab?.contentType === 'browser'
|
||||
? activeUnifiedTab.entityId
|
||||
: browserTabStillOpen
|
||||
? restoredBrowserTabId
|
||||
: (browserTabs[0]?.id ?? null)
|
||||
const activeTabType =
|
||||
activeUnifiedTab?.contentType === 'terminal'
|
||||
? 'terminal'
|
||||
: activeUnifiedTab?.contentType === 'browser'
|
||||
? 'browser'
|
||||
: activeUnifiedTab
|
||||
? 'editor'
|
||||
: restoredTabType === 'browser' && browserTabStillOpen
|
||||
? 'browser'
|
||||
: restoredTabType === 'editor' && fileStillOpen
|
||||
? 'editor'
|
||||
: fileStillOpen
|
||||
? 'editor'
|
||||
: browserTabs.length > 0
|
||||
? 'browser'
|
||||
: 'terminal'
|
||||
const activeTabId =
|
||||
activeUnifiedTab?.contentType === 'terminal'
|
||||
? activeUnifiedTab.entityId
|
||||
: tabStillExists
|
||||
? restoredTabId
|
||||
: (worktreeTabs[0]?.id ?? null)
|
||||
const nextEverActivated = s.everActivatedWorktreeIds.has(workspaceKey)
|
||||
? s.everActivatedWorktreeIds
|
||||
: new Set([...s.everActivatedWorktreeIds, workspaceKey])
|
||||
return {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: workspaceKey,
|
||||
activeWorkspaceKey: workspaceKey,
|
||||
activePendingCreationId: null,
|
||||
activeFileId,
|
||||
activeBrowserTabId,
|
||||
activeTabType,
|
||||
activeTabTypeByWorktree:
|
||||
s.activeTabTypeByWorktree[workspaceKey] === activeTabType
|
||||
? s.activeTabTypeByWorktree
|
||||
: { ...s.activeTabTypeByWorktree, [workspaceKey]: activeTabType },
|
||||
activeTabId,
|
||||
everActivatedWorktreeIds: nextEverActivated,
|
||||
folderWorkspaces: workspace.isUnread
|
||||
? s.folderWorkspaces.map((entry) =>
|
||||
entry.id === folderWorkspaceId ? { ...entry, isUnread: false } : entry
|
||||
)
|
||||
: s.folderWorkspaces
|
||||
}
|
||||
})
|
||||
if (workspace.isUnread) {
|
||||
void get().updateFolderWorkspace(folderWorkspaceId, { isUnread: false })
|
||||
}
|
||||
},
|
||||
|
||||
allWorktrees: () => Object.values(get().worktreesByRepo).flat(),
|
||||
|
||||
getKnownWorktreeById: (worktreeId) => findKnownWorktreeById(get(), worktreeId),
|
||||
|
|
|
|||
|
|
@ -382,6 +382,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
|
|||
schemaVersion: SCHEMA_VERSION,
|
||||
repos: [],
|
||||
projectGroups: [],
|
||||
folderWorkspaces: [],
|
||||
sparsePresetsByRepo: {},
|
||||
worktreeMeta: {},
|
||||
worktreeLineageById: {},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
export type FolderWorkspacePathStatusReason =
|
||||
| 'missing'
|
||||
| 'not-directory'
|
||||
| 'unavailable'
|
||||
| 'ambiguous-connection'
|
||||
|
||||
export const FOLDER_WORKSPACE_PATH_STATUS_TTL_MS = 10_000
|
||||
|
||||
export type FolderWorkspacePathStatusRequest =
|
||||
| { scope: 'folder-workspace'; folderWorkspaceId: string }
|
||||
| { scope: 'project-group'; projectGroupId: string }
|
||||
|
||||
export type FolderWorkspacePathStatus = {
|
||||
path: string
|
||||
exists: boolean
|
||||
reason?: FolderWorkspacePathStatusReason
|
||||
}
|
||||
|
||||
export function isConfirmedStaleFolderPathStatus(
|
||||
status: FolderWorkspacePathStatus | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
status?.exists === false && (status.reason === 'missing' || status.reason === 'not-directory')
|
||||
)
|
||||
}
|
||||
|
||||
export function blocksFolderWorkspaceActivation(
|
||||
status: FolderWorkspacePathStatus | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
isConfirmedStaleFolderPathStatus(status) ||
|
||||
(status?.exists === false && status.reason === 'ambiguous-connection')
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { FolderWorkspace } from './types'
|
||||
import { folderWorkspaceToWorktree } from './folder-workspace-worktree'
|
||||
|
||||
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
|
||||
return {
|
||||
...overrides,
|
||||
id: overrides.id ?? 'folder-workspace-1',
|
||||
projectGroupId: overrides.projectGroupId ?? 'group-1',
|
||||
name: overrides.name ?? 'Refund fix',
|
||||
folderPath: overrides.folderPath ?? '/workspace/platform',
|
||||
linkedTask: overrides.linkedTask ?? null,
|
||||
comment: overrides.comment ?? '',
|
||||
isArchived: overrides.isArchived ?? false,
|
||||
isUnread: overrides.isUnread ?? false,
|
||||
isPinned: overrides.isPinned ?? false,
|
||||
sortOrder: overrides.sortOrder ?? 1,
|
||||
manualOrder: overrides.manualOrder,
|
||||
workspaceStatus: overrides.workspaceStatus,
|
||||
lastActivityAt: overrides.lastActivityAt ?? 2,
|
||||
createdAt: overrides.createdAt ?? 3,
|
||||
updatedAt: overrides.updatedAt ?? 4
|
||||
}
|
||||
}
|
||||
|
||||
describe('folderWorkspaceToWorktree', () => {
|
||||
it('projects attached issue tasks without creating linked PR metadata', () => {
|
||||
const githubIssue = folderWorkspaceToWorktree(
|
||||
makeFolderWorkspace({
|
||||
linkedTask: {
|
||||
provider: 'github',
|
||||
type: 'issue',
|
||||
number: 42,
|
||||
title: 'Refund flow fails',
|
||||
url: 'https://github.com/acme/app/issues/42'
|
||||
}
|
||||
})
|
||||
)
|
||||
const gitlabIssue = folderWorkspaceToWorktree(
|
||||
makeFolderWorkspace({
|
||||
linkedTask: {
|
||||
provider: 'gitlab',
|
||||
type: 'issue',
|
||||
number: 7,
|
||||
title: 'Import fails',
|
||||
url: 'https://gitlab.com/acme/app/-/issues/7'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(githubIssue).toMatchObject({
|
||||
linkedIssue: 42,
|
||||
linkedPR: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null
|
||||
})
|
||||
expect(gitlabIssue).toMatchObject({
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('projects Linear tasks by identifier', () => {
|
||||
const worktree = folderWorkspaceToWorktree(
|
||||
makeFolderWorkspace({
|
||||
linkedTask: {
|
||||
provider: 'linear',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
title: 'Polish folder workspaces',
|
||||
url: 'https://linear.app/acme/issue/ENG-123',
|
||||
linearIdentifier: 'ENG-123'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(worktree.linkedLinearIssue).toBe('ENG-123')
|
||||
expect(worktree.linkedPR).toBeNull()
|
||||
expect(worktree.linkedGitLabMR).toBeNull()
|
||||
})
|
||||
|
||||
it('projects first-message rename state for folder workspace cards', () => {
|
||||
const worktree = folderWorkspaceToWorktree(
|
||||
makeFolderWorkspace({
|
||||
createdWithAgent: 'codex',
|
||||
pendingFirstAgentMessageRename: true,
|
||||
firstAgentMessageRenameError: 'No model configured'
|
||||
})
|
||||
)
|
||||
|
||||
expect(worktree).toMatchObject({
|
||||
createdWithAgent: 'codex',
|
||||
pendingFirstAgentMessageRename: true,
|
||||
firstAgentMessageRenameError: 'No model configured'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps review-style tasks attached only to the folder workspace record', () => {
|
||||
const githubPr = folderWorkspaceToWorktree(
|
||||
makeFolderWorkspace({
|
||||
linkedTask: {
|
||||
provider: 'github',
|
||||
type: 'pr',
|
||||
number: 99,
|
||||
title: 'Feature branch',
|
||||
url: 'https://github.com/acme/app/pull/99'
|
||||
}
|
||||
})
|
||||
)
|
||||
const gitlabMr = folderWorkspaceToWorktree(
|
||||
makeFolderWorkspace({
|
||||
linkedTask: {
|
||||
provider: 'gitlab',
|
||||
type: 'mr',
|
||||
number: 12,
|
||||
title: 'Feature branch',
|
||||
url: 'https://gitlab.com/acme/app/-/merge_requests/12'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(githubPr.linkedPR).toBeNull()
|
||||
expect(githubPr.linkedIssue).toBeNull()
|
||||
expect(gitlabMr.linkedGitLabMR).toBeNull()
|
||||
expect(gitlabMr.linkedGitLabIssue).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { FolderWorkspace, Worktree } from './types'
|
||||
import { folderWorkspaceKey } from './workspace-scope'
|
||||
|
||||
export function folderWorkspaceToWorktree(folderWorkspace: FolderWorkspace): Worktree {
|
||||
const linkedTask = folderWorkspace.linkedTask
|
||||
return {
|
||||
id: folderWorkspaceKey(folderWorkspace.id),
|
||||
repoId: `folder-workspace:${folderWorkspace.projectGroupId}`,
|
||||
displayName: folderWorkspace.name,
|
||||
comment: folderWorkspace.comment,
|
||||
linkedIssue:
|
||||
linkedTask?.provider === 'github' && linkedTask.type === 'issue' ? linkedTask.number : null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue:
|
||||
linkedTask?.provider === 'linear' ? (linkedTask.linearIdentifier ?? null) : null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue:
|
||||
linkedTask?.provider === 'gitlab' && linkedTask.type === 'issue' ? linkedTask.number : null,
|
||||
isArchived: folderWorkspace.isArchived,
|
||||
isUnread: folderWorkspace.isUnread,
|
||||
isPinned: folderWorkspace.isPinned,
|
||||
sortOrder: folderWorkspace.sortOrder,
|
||||
manualOrder: folderWorkspace.manualOrder,
|
||||
lastActivityAt: folderWorkspace.lastActivityAt,
|
||||
createdAt: folderWorkspace.createdAt,
|
||||
createdWithAgent: folderWorkspace.createdWithAgent,
|
||||
pendingFirstAgentMessageRename: folderWorkspace.pendingFirstAgentMessageRename,
|
||||
firstAgentMessageRenameError: folderWorkspace.firstAgentMessageRenameError,
|
||||
workspaceStatus: folderWorkspace.workspaceStatus,
|
||||
path: folderWorkspace.folderPath,
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isSparse: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import type { FolderWorkspace, FolderWorkspaceLinkedTask, ProjectGroup } from './types'
|
||||
import { isTuiAgent } from './tui-agent-config'
|
||||
|
||||
export function normalizeFolderWorkspaceName(
|
||||
name: string | null | undefined,
|
||||
fallback = 'Untitled workspace'
|
||||
): string {
|
||||
const trimmed = typeof name === 'string' ? name.trim() : ''
|
||||
return trimmed.length > 0 ? trimmed : fallback
|
||||
}
|
||||
|
||||
export function normalizeFolderWorkspaceLinkedTask(
|
||||
value: unknown
|
||||
): FolderWorkspaceLinkedTask | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
const raw = value as Partial<FolderWorkspaceLinkedTask>
|
||||
if (
|
||||
raw.provider !== 'github' &&
|
||||
raw.provider !== 'gitlab' &&
|
||||
raw.provider !== 'linear' &&
|
||||
raw.provider !== 'jira'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (raw.type !== 'issue' && raw.type !== 'pr' && raw.type !== 'mr') {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
typeof raw.number !== 'number' ||
|
||||
!Number.isFinite(raw.number) ||
|
||||
typeof raw.title !== 'string' ||
|
||||
raw.title.trim().length === 0 ||
|
||||
typeof raw.url !== 'string' ||
|
||||
raw.url.trim().length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
provider: raw.provider,
|
||||
type: raw.type,
|
||||
number: raw.number,
|
||||
title: raw.title.trim(),
|
||||
url: raw.url.trim(),
|
||||
...(typeof raw.linearIdentifier === 'string' && raw.linearIdentifier.trim().length > 0
|
||||
? { linearIdentifier: raw.linearIdentifier.trim() }
|
||||
: {}),
|
||||
...(typeof raw.jiraIdentifier === 'string' && raw.jiraIdentifier.trim().length > 0
|
||||
? { jiraIdentifier: raw.jiraIdentifier.trim() }
|
||||
: {}),
|
||||
...(typeof raw.repoId === 'string' && raw.repoId.trim().length > 0
|
||||
? { repoId: raw.repoId.trim() }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeFolderWorkspaces(
|
||||
value: unknown,
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
): FolderWorkspace[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
const folderGroups = new Map<string, ProjectGroup>()
|
||||
for (const group of projectGroups) {
|
||||
if (group.parentPath) {
|
||||
folderGroups.set(group.id, group)
|
||||
}
|
||||
}
|
||||
|
||||
const workspaces: FolderWorkspace[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const candidate of value) {
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
continue
|
||||
}
|
||||
const raw = candidate as Partial<FolderWorkspace>
|
||||
if (
|
||||
typeof raw.id !== 'string' ||
|
||||
raw.id.trim().length === 0 ||
|
||||
seen.has(raw.id) ||
|
||||
typeof raw.projectGroupId !== 'string' ||
|
||||
!folderGroups.has(raw.projectGroupId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const group = folderGroups.get(raw.projectGroupId)
|
||||
const folderPath =
|
||||
typeof raw.folderPath === 'string' && raw.folderPath.trim().length > 0
|
||||
? raw.folderPath
|
||||
: group?.parentPath
|
||||
if (!folderPath) {
|
||||
continue
|
||||
}
|
||||
const now = Date.now()
|
||||
seen.add(raw.id)
|
||||
workspaces.push({
|
||||
id: raw.id,
|
||||
projectGroupId: raw.projectGroupId,
|
||||
name: normalizeFolderWorkspaceName(raw.name),
|
||||
folderPath,
|
||||
connectionId:
|
||||
typeof raw.connectionId === 'string'
|
||||
? raw.connectionId
|
||||
: raw.connectionId === null
|
||||
? null
|
||||
: (group?.connectionId ?? null),
|
||||
linkedTask: normalizeFolderWorkspaceLinkedTask(raw.linkedTask),
|
||||
comment: typeof raw.comment === 'string' ? raw.comment : '',
|
||||
isArchived: raw.isArchived === true,
|
||||
isUnread: raw.isUnread === true,
|
||||
isPinned: raw.isPinned === true,
|
||||
sortOrder:
|
||||
typeof raw.sortOrder === 'number' && Number.isFinite(raw.sortOrder) ? raw.sortOrder : now,
|
||||
...(typeof raw.manualOrder === 'number' && Number.isFinite(raw.manualOrder)
|
||||
? { manualOrder: raw.manualOrder }
|
||||
: {}),
|
||||
...(typeof raw.workspaceStatus === 'string' && raw.workspaceStatus.trim().length > 0
|
||||
? { workspaceStatus: raw.workspaceStatus }
|
||||
: {}),
|
||||
...(isTuiAgent(raw.createdWithAgent) ? { createdWithAgent: raw.createdWithAgent } : {}),
|
||||
...(raw.pendingFirstAgentMessageRename === true
|
||||
? { pendingFirstAgentMessageRename: true }
|
||||
: {}),
|
||||
...(typeof raw.firstAgentMessageRenameError === 'string'
|
||||
? { firstAgentMessageRenameError: raw.firstAgentMessageRenameError }
|
||||
: raw.firstAgentMessageRenameError === null
|
||||
? { firstAgentMessageRenameError: null }
|
||||
: {}),
|
||||
lastActivityAt:
|
||||
typeof raw.lastActivityAt === 'number' && Number.isFinite(raw.lastActivityAt)
|
||||
? raw.lastActivityAt
|
||||
: 0,
|
||||
createdAt:
|
||||
typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : now,
|
||||
updatedAt:
|
||||
typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : now
|
||||
})
|
||||
}
|
||||
return workspaces.sort(
|
||||
(left, right) => right.sortOrder - left.sortOrder || left.name.localeCompare(right.name)
|
||||
)
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ export function normalizeProjectGroupName(name: string, fallback = 'Untitled gro
|
|||
export function createProjectGroup(input: {
|
||||
name: string
|
||||
parentPath?: string | null
|
||||
connectionId?: string | null
|
||||
parentGroupId?: string | null
|
||||
createdFrom: ProjectGroupCreatedFrom
|
||||
tabOrder: number
|
||||
|
|
@ -28,6 +29,7 @@ export function createProjectGroup(input: {
|
|||
id: createProjectGroupId(),
|
||||
name: normalizeProjectGroupName(input.name),
|
||||
parentPath: input.parentPath ?? null,
|
||||
connectionId: input.connectionId ?? null,
|
||||
parentGroupId: input.parentGroupId ?? null,
|
||||
createdFrom: input.createdFrom,
|
||||
tabOrder: input.tabOrder,
|
||||
|
|
@ -58,6 +60,12 @@ export function normalizeProjectGroups(value: unknown): ProjectGroup[] {
|
|||
id: raw.id,
|
||||
name: normalizeProjectGroupName(typeof raw.name === 'string' ? raw.name : ''),
|
||||
parentPath: typeof raw.parentPath === 'string' ? raw.parentPath : null,
|
||||
connectionId:
|
||||
typeof raw.connectionId === 'string'
|
||||
? raw.connectionId
|
||||
: raw.connectionId === null
|
||||
? null
|
||||
: null,
|
||||
parentGroupId: typeof raw.parentGroupId === 'string' ? raw.parentGroupId : null,
|
||||
createdFrom:
|
||||
raw.createdFrom === 'manual' ||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,8 @@ export type ProjectGroup = {
|
|||
id: string
|
||||
name: string
|
||||
parentPath: string | null
|
||||
/** SSH target ID for folder-backed groups imported from a remote root. */
|
||||
connectionId?: string | null
|
||||
parentGroupId: string | null
|
||||
createdFrom: ProjectGroupCreatedFrom
|
||||
tabOrder: number
|
||||
|
|
@ -145,6 +147,47 @@ export type ProjectGroup = {
|
|||
updatedAt: number
|
||||
}
|
||||
|
||||
export type WorkspaceScope =
|
||||
| { type: 'worktree'; worktreeId: string }
|
||||
| { type: 'folder'; folderWorkspaceId: string }
|
||||
|
||||
export type WorkspaceKey = `worktree:${string}` | `folder:${string}`
|
||||
|
||||
export type FolderWorkspace = {
|
||||
id: string
|
||||
projectGroupId: string
|
||||
name: string
|
||||
folderPath: string
|
||||
/** SSH target ID for folder workspaces whose folder path lives remotely. */
|
||||
connectionId?: string | null
|
||||
linkedTask: FolderWorkspaceLinkedTask | null
|
||||
comment: string
|
||||
isArchived: boolean
|
||||
isUnread: boolean
|
||||
isPinned: boolean
|
||||
sortOrder: number
|
||||
/** User-authored sidebar ordering. Higher values render earlier in Manual sort. */
|
||||
manualOrder?: number
|
||||
workspaceStatus?: WorkspaceStatus
|
||||
createdWithAgent?: TuiAgent
|
||||
pendingFirstAgentMessageRename?: boolean
|
||||
firstAgentMessageRenameError?: string | null
|
||||
lastActivityAt: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type FolderWorkspaceLinkedTask = {
|
||||
provider: 'github' | 'gitlab' | 'linear' | 'jira'
|
||||
type: 'issue' | 'pr' | 'mr'
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
linearIdentifier?: string
|
||||
jiraIdentifier?: string
|
||||
repoId?: string
|
||||
}
|
||||
|
||||
export type NestedRepoScanOptions = {
|
||||
maxDepth?: number
|
||||
maxRepos?: number
|
||||
|
|
@ -699,8 +742,11 @@ export type PersistedOpenFile = {
|
|||
|
||||
export type WorkspaceSessionState = {
|
||||
activeRepoId: string | null
|
||||
/** Scope-aware active owner for folder workspaces. Legacy worktree UI still reads activeWorktreeId. */
|
||||
activeWorkspaceKey?: WorkspaceKey | null
|
||||
activeWorktreeId: string | null
|
||||
activeTabId: string | null
|
||||
/** Keys may be legacy raw worktree IDs or canonical WorkspaceKey values. */
|
||||
tabsByWorktree: Record<string, TerminalTab[]>
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
|
||||
/** Worktree IDs that had at least one tab with a live PTY at shutdown.
|
||||
|
|
@ -2890,6 +2936,7 @@ export type PersistedState = {
|
|||
schemaVersion: number
|
||||
repos: Repo[]
|
||||
projectGroups: ProjectGroup[]
|
||||
folderWorkspaces: FolderWorkspace[]
|
||||
/** Sparse-checkout presets keyed by repoId. Empty record on first launch;
|
||||
* presets are managed from the new-workspace composer and repo settings. */
|
||||
sparsePresetsByRepo: Record<string, SparsePreset[]>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import type { WorkspaceKey, WorkspaceScope } from './types'
|
||||
|
||||
export function worktreeWorkspaceKey(worktreeId: string): WorkspaceKey {
|
||||
return `worktree:${worktreeId}`
|
||||
}
|
||||
|
||||
export function folderWorkspaceKey(folderWorkspaceId: string): WorkspaceKey {
|
||||
return `folder:${folderWorkspaceId}`
|
||||
}
|
||||
|
||||
export function workspaceKeyFromScope(scope: WorkspaceScope): WorkspaceKey {
|
||||
return scope.type === 'worktree'
|
||||
? worktreeWorkspaceKey(scope.worktreeId)
|
||||
: folderWorkspaceKey(scope.folderWorkspaceId)
|
||||
}
|
||||
|
||||
export function parseWorkspaceKey(value: string): WorkspaceScope | null {
|
||||
if (value.startsWith('worktree:')) {
|
||||
const worktreeId = value.slice('worktree:'.length)
|
||||
return worktreeId.length > 0 ? { type: 'worktree', worktreeId } : null
|
||||
}
|
||||
if (value.startsWith('folder:')) {
|
||||
const folderWorkspaceId = value.slice('folder:'.length)
|
||||
return folderWorkspaceId.length > 0 ? { type: 'folder', folderWorkspaceId } : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function isWorkspaceKey(value: string): value is WorkspaceKey {
|
||||
return parseWorkspaceKey(value) !== null
|
||||
}
|
||||
|
|
@ -14,12 +14,14 @@ import type {
|
|||
TabGroupLayoutNode,
|
||||
TerminalPaneLayoutNode,
|
||||
TuiAgent,
|
||||
WorkspaceKey,
|
||||
WorkspaceSessionState
|
||||
} from './types'
|
||||
import { isValidTerminalTabId } from './terminal-tab-id'
|
||||
import { isTuiAgent } from './tui-agent-config'
|
||||
import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history'
|
||||
import { normalizeAgentProviderSession, RESUMABLE_TUI_AGENTS } from './agent-session-resume'
|
||||
import { isWorkspaceKey } from './workspace-scope'
|
||||
|
||||
// ─── Terminal pane layout (recursive) ───────────────────────────────
|
||||
|
||||
|
|
@ -28,6 +30,9 @@ const terminalTabIdSchema = z
|
|||
.string()
|
||||
.min(1)
|
||||
.refine(isValidTerminalTabId, 'terminal tab id must not contain ":"')
|
||||
const workspaceKeySchema = z.custom<WorkspaceKey>(
|
||||
(value) => typeof value === 'string' && isWorkspaceKey(value)
|
||||
)
|
||||
|
||||
// Why: z.lazy + type annotation keeps the recursive inference working without
|
||||
// forcing zod to resolve the whole tree at definition time.
|
||||
|
|
@ -262,6 +267,7 @@ const browserHistoryEntriesSchema = z
|
|||
|
||||
export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.object({
|
||||
activeRepoId: z.string().nullable(),
|
||||
activeWorkspaceKey: workspaceKeySchema.nullable().optional(),
|
||||
activeWorktreeId: z.string().nullable(),
|
||||
activeTabId: z.string().nullable(),
|
||||
tabsByWorktree: z.record(z.string(), z.array(terminalTabSchema)),
|
||||
|
|
|
|||
Loading…
Reference in New Issue