mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 16:02:25 +00:00
Revert Projects sidebar +Add button (#4459)
* Revert "Make add project create action a button (#4439)" This reverts commit709adbc761. * Revert "Make adding projects discoverable in sidebar (#4426)" This reverts commit58d36e4c1c. Conflict resolution notes: - Deleted SidebarHeader.test.tsx (introduced by #4426; subsequent edits from #2734 are dropped along with the test file). - Restored SidebarHeader.tsx to its pre-#4426 state. The contextual-tour anchor data-contextual-tour-target="workspace-create-control" added by #2734 no longer has a host element; the workspace-creation tour will need to be re-anchored separately. --------- Co-authored-by: orca-bot <bot@stably.ai>
This commit is contained in:
@@ -6,7 +6,6 @@ import {
|
||||
ArrowLeft,
|
||||
Globe,
|
||||
Monitor,
|
||||
FolderPlus,
|
||||
FolderTree,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
@@ -184,10 +183,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
// Why: a dropped path is modal data, so ordinary state updates must not
|
||||
// re-run the import while the Add Project dialog advances through steps.
|
||||
const droppedLocalPathHandledRef = useRef<string | null>(null)
|
||||
// Why: header add-menu routing is modal data; handle it once per modal open
|
||||
// so later state updates don't kick the user back to the requested entry step.
|
||||
const initialStepHandledRef = useRef<string | null>(null)
|
||||
const autoBrowseHandledRef = useRef(false)
|
||||
// Why: track whether we've already auto-filled for this entry into the clone step,
|
||||
// so a late settings hydration still gets a chance to set the default.
|
||||
const cloneStepAutoFilledRef = useRef(false)
|
||||
@@ -279,11 +274,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
const isOpen = activeModal === 'add-repo'
|
||||
const droppedLocalPath =
|
||||
typeof modalData.droppedLocalPath === 'string' ? modalData.droppedLocalPath : ''
|
||||
const initialStep =
|
||||
modalData.initialStep === 'clone' || modalData.initialStep === 'remote'
|
||||
? modalData.initialStep
|
||||
: 'add'
|
||||
const autoBrowse = modalData.autoBrowse === true
|
||||
const projectId = addedRepo?.id ?? ''
|
||||
const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
|
||||
|
||||
@@ -361,8 +351,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
droppedLocalPathHandledRef.current = null
|
||||
initialStepHandledRef.current = null
|
||||
autoBrowseHandledRef.current = false
|
||||
resetState()
|
||||
}
|
||||
}, [isOpen, resetState])
|
||||
@@ -502,35 +490,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
}
|
||||
}, [handleAddLocalPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
const routeKey = `${initialStep}:${autoBrowse ? 'browse' : 'manual'}`
|
||||
if (initialStepHandledRef.current === routeKey) {
|
||||
return
|
||||
}
|
||||
initialStepHandledRef.current = routeKey
|
||||
if (initialStep === 'clone') {
|
||||
setCloneError(null)
|
||||
setStep('clone')
|
||||
return
|
||||
}
|
||||
if (initialStep === 'remote') {
|
||||
void handleOpenRemoteStep()
|
||||
return
|
||||
}
|
||||
setStep('add')
|
||||
}, [autoBrowse, handleOpenRemoteStep, initialStep, isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !autoBrowse || autoBrowseHandledRef.current) {
|
||||
return
|
||||
}
|
||||
autoBrowseHandledRef.current = true
|
||||
void handleBrowse()
|
||||
}, [autoBrowse, handleBrowse, isOpen])
|
||||
|
||||
const handleImportNestedRepos = useCallback(
|
||||
async (mode: 'group' | 'separate') => {
|
||||
const attemptId = nestedAttemptId
|
||||
@@ -1245,20 +1204,20 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
<span>Want to import many repos at once? Select the parent folder.</span>
|
||||
</div>
|
||||
|
||||
{/* Secondary link rather than a fourth card — create-from-scratch
|
||||
is a less common path than importing. See orca#763. */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreateError(null)
|
||||
setStep('create')
|
||||
}}
|
||||
disabled={isAdding}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
<FolderPlus className="size-3.5" />
|
||||
Or start a new project from scratch
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : step === 'remote' ? (
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type * as ReactModule from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
|
||||
type ButtonCapture = {
|
||||
label: string
|
||||
ariaLabel?: string
|
||||
dataContextualTourTarget?: string
|
||||
size?: string
|
||||
onClick?: () => unknown
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type MenuItemCapture = {
|
||||
label: string
|
||||
dataContextualTourTarget?: string
|
||||
onSelect?: () => unknown
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buttons: [] as ButtonCapture[],
|
||||
menuItems: [] as MenuItemCapture[],
|
||||
state: {
|
||||
openModal: vi.fn(),
|
||||
repos: [] as Repo[],
|
||||
groupBy: 'repo' as 'repo' | 'status',
|
||||
recordFeatureInteraction: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
function textContent(node: ReactModule.ReactNode): string {
|
||||
if (node == null || typeof node === 'boolean') {
|
||||
return ''
|
||||
}
|
||||
if (typeof node === 'string' || typeof node === 'number') {
|
||||
return String(node)
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(textContent).join('')
|
||||
}
|
||||
if (typeof node === 'object' && 'props' in node) {
|
||||
return textContent((node as { props?: { children?: ReactModule.ReactNode } }).props?.children)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = Object.assign(
|
||||
(selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
|
||||
{
|
||||
getState: () => mocks.state,
|
||||
setState: (next: Partial<typeof mocks.state>) => {
|
||||
Object.assign(mocks.state, next)
|
||||
}
|
||||
}
|
||||
)
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
size,
|
||||
...props
|
||||
}: {
|
||||
children: ReactModule.ReactNode
|
||||
onClick?: () => unknown
|
||||
disabled?: boolean
|
||||
size?: string
|
||||
'aria-label'?: string
|
||||
'data-contextual-tour-target'?: string
|
||||
}) => {
|
||||
mocks.buttons.push({
|
||||
label: textContent(children),
|
||||
ariaLabel: props['aria-label'],
|
||||
dataContextualTourTarget: props['data-contextual-tour-target'],
|
||||
size,
|
||||
onClick,
|
||||
disabled
|
||||
})
|
||||
return (
|
||||
<button
|
||||
data-contextual-tour-target={props['data-contextual-tour-target']}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactModule.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactModule.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactModule.ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactModule.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactModule.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactModule.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuLabel: ({ children }: { children: ReactModule.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuShortcut: ({ children }: { children: ReactModule.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
disabled,
|
||||
...props
|
||||
}: {
|
||||
children: ReactModule.ReactNode
|
||||
onSelect?: () => unknown
|
||||
disabled?: boolean
|
||||
'data-contextual-tour-target'?: string
|
||||
}) => {
|
||||
mocks.menuItems.push({
|
||||
label: textContent(children),
|
||||
dataContextualTourTarget: props['data-contextual-tour-target'],
|
||||
onSelect,
|
||||
disabled
|
||||
})
|
||||
return (
|
||||
<button disabled={disabled} onClick={onSelect}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./SidebarWorkspaceOptionsMenu', () => ({
|
||||
default: () => <div data-testid="workspace-options-menu" />
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceKanbanDrawer', () => ({
|
||||
default: ({ open }: { open: boolean }) => (open ? <div data-testid="workspace-board" /> : null)
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useShortcutLabel: () => '⌘N'
|
||||
}))
|
||||
|
||||
function findButton(predicate: (entry: ButtonCapture) => boolean): ButtonCapture | undefined {
|
||||
return mocks.buttons.find(predicate)
|
||||
}
|
||||
|
||||
function findMenuItem(predicate: (entry: MenuItemCapture) => boolean): MenuItemCapture | undefined {
|
||||
return mocks.menuItems.find(predicate)
|
||||
}
|
||||
|
||||
describe('SidebarHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.buttons = []
|
||||
mocks.menuItems = []
|
||||
mocks.state.repos = []
|
||||
mocks.state.groupBy = 'repo'
|
||||
})
|
||||
|
||||
async function render(): Promise<string> {
|
||||
const { default: SidebarHeader } = await import('./SidebarHeader')
|
||||
return renderToStaticMarkup(<SidebarHeader />)
|
||||
}
|
||||
|
||||
it('renders an enabled Add to Orca menu with zero repos and routes local folder to add-repo', async () => {
|
||||
await render()
|
||||
|
||||
const addToOrca = findButton((b) => b.ariaLabel === 'Add to Orca')
|
||||
expect(addToOrca).toBeDefined()
|
||||
expect(addToOrca?.disabled).toBeFalsy()
|
||||
|
||||
const openLocalFolder = findMenuItem((b) => b.label.includes('Open local folder'))
|
||||
expect(openLocalFolder).toBeDefined()
|
||||
|
||||
openLocalFolder?.onSelect?.()
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('add-repo', { autoBrowse: true })
|
||||
})
|
||||
|
||||
it('keeps New worktree disabled with zero repos inside the Add menu', async () => {
|
||||
await render()
|
||||
|
||||
const newWorkspace = findMenuItem((b) => b.label.includes('New worktree'))
|
||||
expect(newWorkspace?.disabled).toBe(true)
|
||||
|
||||
newWorkspace?.onSelect?.()
|
||||
expect(mocks.state.openModal).not.toHaveBeenCalledWith(
|
||||
'new-workspace-composer',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('anchors the workspace creation tour to the visible Add trigger when a worktree can be created', async () => {
|
||||
mocks.state.repos = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
}
|
||||
]
|
||||
|
||||
await render()
|
||||
|
||||
const addToOrca = findButton((b) => b.ariaLabel === 'Add to Orca')
|
||||
expect(addToOrca?.dataContextualTourTarget).toBe('workspace-create-control')
|
||||
|
||||
const newWorkspace = findMenuItem((b) => b.label.includes('New worktree'))
|
||||
// Why: closed dropdown items are not reliable tour anchors; the visible
|
||||
// trigger stays measurable while the handoff remains on this menu action.
|
||||
expect(newWorkspace?.dataContextualTourTarget).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows the compact visible Add label under repo grouping', async () => {
|
||||
mocks.state.groupBy = 'repo'
|
||||
await render()
|
||||
|
||||
const addToOrca = findButton((b) => b.ariaLabel === 'Add to Orca')
|
||||
expect(addToOrca?.label).toContain('Add')
|
||||
expect(addToOrca?.size).toBe('xs')
|
||||
})
|
||||
|
||||
it('renders an icon-only Add to Orca trigger under non-repo grouping', async () => {
|
||||
mocks.state.groupBy = 'status'
|
||||
await render()
|
||||
|
||||
const addToOrca = findButton((b) => b.ariaLabel === 'Add to Orca')
|
||||
// Icon-only: no visible text label, sized to match neighboring controls.
|
||||
expect(addToOrca?.label).toBe('')
|
||||
expect(addToOrca?.size).toBe('icon-xs')
|
||||
|
||||
findMenuItem((b) => b.label.includes('Clone from GitHub'))?.onSelect?.()
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('add-repo', { initialStep: 'clone' })
|
||||
})
|
||||
|
||||
it('still renders the workspace board and Add menu controls', async () => {
|
||||
await render()
|
||||
|
||||
expect(findButton((b) => b.ariaLabel === 'Workspace board')).toBeDefined()
|
||||
expect(findButton((b) => b.ariaLabel === 'Add to Orca')).toBeDefined()
|
||||
expect(findMenuItem((b) => b.label.includes('Remote (SSH) project'))).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not toggle the workspace board path when Add project is selected after opening the board', async () => {
|
||||
await render()
|
||||
|
||||
const workspaceBoard = findButton((b) => b.ariaLabel === 'Workspace board')
|
||||
const remoteProject = findMenuItem((b) => b.label.includes('Remote (SSH) project'))
|
||||
|
||||
workspaceBoard?.onClick?.()
|
||||
expect(mocks.state.recordFeatureInteraction).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.state.recordFeatureInteraction).toHaveBeenCalledWith('workspace-board')
|
||||
|
||||
remoteProject?.onSelect?.()
|
||||
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('add-repo', { initialStep: 'remote' })
|
||||
expect(mocks.state.recordFeatureInteraction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,11 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, FolderPlus, GitBranch, Kanban, Monitor, Plus } from 'lucide-react'
|
||||
import { Kanban, Plus } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import SidebarWorkspaceOptionsMenu from './SidebarWorkspaceOptionsMenu'
|
||||
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
|
||||
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { openWorkspaceCreationComposerWithTourHandoff } from '../contextual-tours/workspace-creation-tour-handoff'
|
||||
|
||||
const SidebarHeader = React.memo(function SidebarHeader() {
|
||||
const newWorktreeShortcutLabel = useShortcutLabel('workspace.create')
|
||||
@@ -26,17 +16,9 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const groupBy = useAppStore((s) => s.groupBy)
|
||||
const canCreateWorkspace = repos.length > 0
|
||||
const isRepoGrouping = groupBy === 'repo'
|
||||
const sidebarTitle = isRepoGrouping ? 'Projects' : 'Workspaces'
|
||||
const sidebarTitle = groupBy === 'repo' ? 'Projects' : 'Workspaces'
|
||||
workspaceBoardOpenRef.current = workspaceBoardOpen
|
||||
|
||||
const openAddRepo = useCallback(
|
||||
(data: Record<string, unknown> = {}) => {
|
||||
openModal('add-repo', data)
|
||||
},
|
||||
[openModal]
|
||||
)
|
||||
|
||||
const openWorkspaceBoard = useCallback(() => {
|
||||
if (workspaceBoardOpenRef.current) {
|
||||
return
|
||||
@@ -112,7 +94,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
||||
<>
|
||||
<div className="mt-2 flex h-8 items-center justify-between px-2 gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate pl-2 pr-0.5 text-xs font-semibold text-muted-foreground/80 select-none">
|
||||
<span className="pl-2 pr-0.5 text-xs font-semibold text-muted-foreground/80 select-none">
|
||||
{sidebarTitle}
|
||||
</span>
|
||||
</div>
|
||||
@@ -141,76 +123,29 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu modal={false} onOpenChange={setWorkspaceBoardMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
// Why: Direction A combines workspace creation and adding a
|
||||
// project under the Projects header, so the affordance reads
|
||||
// as "Add" near the list rather than another bare plus icon.
|
||||
size={isRepoGrouping ? 'xs' : 'icon-xs'}
|
||||
aria-label="Add to Orca"
|
||||
className={isRepoGrouping ? 'gap-1 text-muted-foreground' : 'text-muted-foreground'}
|
||||
data-contextual-tour-target={
|
||||
canCreateWorkspace ? 'workspace-create-control' : undefined
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" strokeWidth={2.25} />
|
||||
{isRepoGrouping ? <span className="text-[11px]">Add</span> : null}
|
||||
{isRepoGrouping ? <ChevronDown className="size-3 opacity-60" /> : null}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6} className="w-64">
|
||||
<DropdownMenuLabel>Worktree</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
size="icon-xs"
|
||||
onClick={() => {
|
||||
if (!canCreateWorkspace) {
|
||||
return
|
||||
}
|
||||
openWorkspaceCreationComposerWithTourHandoff()
|
||||
openModal('new-workspace-composer', { telemetrySource: 'sidebar' })
|
||||
}}
|
||||
aria-label="New workspace"
|
||||
disabled={!canCreateWorkspace}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span>New worktree</span>
|
||||
</span>
|
||||
{canCreateWorkspace ? (
|
||||
<DropdownMenuShortcut>{newWorktreeShortcutLabel}</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Project</DropdownMenuLabel>
|
||||
<DropdownMenuItem onSelect={() => openAddRepo({ autoBrowse: true })}>
|
||||
<FolderPlus className="size-3.5" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span>Open local folder...</span>
|
||||
<span className="text-[11px] leading-4 font-normal text-muted-foreground">
|
||||
Pick a Git repo on this machine
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => openAddRepo({ initialStep: 'clone' })}>
|
||||
<GitBranch className="size-3.5" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span>Clone from GitHub / GitLab...</span>
|
||||
<span className="text-[11px] leading-4 font-normal text-muted-foreground">
|
||||
Paste a repository URL
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => openAddRepo({ initialStep: 'remote' })}>
|
||||
<Monitor className="size-3.5" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span>Remote (SSH) project...</span>
|
||||
<span className="text-[11px] leading-4 font-normal text-muted-foreground">
|
||||
Connect to a repo on a remote host
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Plus className="size-3.5" strokeWidth={2.25} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{canCreateWorkspace
|
||||
? `New workspace (${newWorktreeShortcutLabel})`
|
||||
: 'Add a project to create workspaces'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<WorkspaceKanbanDrawer
|
||||
|
||||
Reference in New Issue
Block a user