mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
Compact worktree project and host pickers with stacked details (#34098)
* Compact worktree project and host pickers with stacked details * Align worktree picker markers and action gutters * Extract WSL connector fixture to satisfy file length limit * Restart CI after resolving upstream fixture conflict
This commit is contained in:
+161
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react'
|
||||
import { defineFixture } from '../../component-fixtures/component-fixture'
|
||||
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
|
||||
import {
|
||||
getProjectHostSetupSelectionKey,
|
||||
type ProjectHostSetupOption
|
||||
} from '@/lib/project-host-setup-options'
|
||||
import { NewWorkspaceComposerProjectSection } from './NewWorkspaceComposerProjectSection'
|
||||
|
||||
const projects: NewWorkspaceProjectOption[] = [
|
||||
{
|
||||
kind: 'project',
|
||||
id: 'orca',
|
||||
projectId: 'orca',
|
||||
displayName: 'orca',
|
||||
badgeColor: 'var(--muted-foreground)',
|
||||
detail: 'stablyai/orca'
|
||||
},
|
||||
{
|
||||
kind: 'project',
|
||||
id: 'personal-orca',
|
||||
projectId: 'personal-orca',
|
||||
displayName: 'orca',
|
||||
badgeColor: 'var(--muted-foreground)',
|
||||
detail: 'personal/orca'
|
||||
},
|
||||
{
|
||||
kind: 'project',
|
||||
id: 'api',
|
||||
projectId: 'api',
|
||||
displayName: 'platform-api',
|
||||
badgeColor: 'var(--muted-foreground)',
|
||||
detail: 'stablyai/platform-api'
|
||||
},
|
||||
{
|
||||
kind: 'project-group',
|
||||
id: 'folder',
|
||||
projectGroupId: 'folder',
|
||||
displayName: 'Design workspace',
|
||||
badgeColor: 'var(--muted-foreground)',
|
||||
detail: '/Users/alice/Projects/design',
|
||||
parentPath: '/Users/alice/Projects/design',
|
||||
connectionId: null
|
||||
}
|
||||
]
|
||||
const hosts: ProjectHostSetupOption[] = [
|
||||
{
|
||||
kind: 'ready',
|
||||
id: 'local',
|
||||
projectId: 'orca',
|
||||
repoId: 'orca-local',
|
||||
hostId: 'local',
|
||||
label: 'MacBook Pro',
|
||||
detail: 'Local',
|
||||
path: '/Users/alice/Projects/orca'
|
||||
},
|
||||
{
|
||||
kind: 'ready',
|
||||
id: 'remote',
|
||||
projectId: 'orca',
|
||||
repoId: 'orca-remote',
|
||||
hostId: 'ssh:devbox',
|
||||
label: 'Dev server',
|
||||
detail: 'SSH',
|
||||
path: '/home/alice/projects/orca'
|
||||
},
|
||||
{
|
||||
kind: 'needs-setup',
|
||||
id: 'windows',
|
||||
projectId: 'orca',
|
||||
hostId: 'runtime:windows',
|
||||
label: 'Windows workstation',
|
||||
detail: 'Project location not set',
|
||||
isAvailable: true,
|
||||
attention: false,
|
||||
canSetLocation: true
|
||||
}
|
||||
]
|
||||
|
||||
function ProjectAndHostPreview({
|
||||
folderOnly = false
|
||||
}: {
|
||||
folderOnly?: boolean
|
||||
}): React.JSX.Element {
|
||||
const [project, setProject] = useState(folderOnly ? 'folder' : 'orca')
|
||||
const [host, setHost] = useState('local')
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-border bg-background p-4">
|
||||
<h1 className="text-base font-semibold">New worktree</h1>
|
||||
<NewWorkspaceComposerProjectSection
|
||||
projectError={null}
|
||||
selectedRepoConnectionId={null}
|
||||
selectedRepoRequiresConnection={false}
|
||||
selectedRepoConnectInProgress={false}
|
||||
projectOptions={projects}
|
||||
selectedProjectId={project}
|
||||
onProjectChange={setProject}
|
||||
projectDescriptionId="fixture-project-description"
|
||||
onAddProject={() => {}}
|
||||
focusNameInput={() => {}}
|
||||
shouldShowRunTargetPicker={!folderOnly}
|
||||
projectHostSetupOptions={hosts}
|
||||
selectedProjectHostSetupId={host}
|
||||
handleProjectHostSetupChange={(key) => {
|
||||
const option = hosts.find(
|
||||
(candidate) => getProjectHostSetupSelectionKey(candidate) === key
|
||||
)
|
||||
if (option) {
|
||||
setHost(option.id)
|
||||
}
|
||||
}}
|
||||
ephemeralVmRecipes={[]}
|
||||
handleAddSshHost={() => {}}
|
||||
handleAddRemoteServer={() => {}}
|
||||
handleConnectRunTargetHost={async () => {}}
|
||||
handleSetLocation={() => {}}
|
||||
onConnectSelectedRepo={async () => {}}
|
||||
sshStatusLabel="Connected"
|
||||
connectButtonLabel="Connect"
|
||||
selectedProjectName="orca"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const closed = defineFixture({ width: 560, render: () => <ProjectAndHostPreview /> })
|
||||
export const narrow = defineFixture({ width: 360, render: () => <ProjectAndHostPreview /> })
|
||||
export const folderOnly = defineFixture({
|
||||
width: 560,
|
||||
render: () => <ProjectAndHostPreview folderOnly />
|
||||
})
|
||||
export const projectMenu = defineFixture({
|
||||
...closed,
|
||||
interact: (frame) => {
|
||||
frame.querySelector<HTMLInputElement>('[aria-label="Project"][role="combobox"]')?.focus()
|
||||
}
|
||||
})
|
||||
export const hostMenu = defineFixture({
|
||||
...closed,
|
||||
interact: (frame) => {
|
||||
frame.querySelector<HTMLInputElement>('[aria-label="Run on"][role="combobox"]')?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
export const narrowProjectMenu = defineFixture({ ...projectMenu, width: 360 })
|
||||
export const narrowHostMenu = defineFixture({ ...hostMenu, width: 360 })
|
||||
|
||||
function tooltipFixture(attribute: string) {
|
||||
return defineFixture({
|
||||
...closed,
|
||||
interact: async (frame) => {
|
||||
frame
|
||||
.querySelector(`div[${attribute}]`)
|
||||
?.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }))
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const projectTooltip = tooltipFixture('data-project-combobox-root')
|
||||
export const hostTooltip = tooltipFixture('data-run-target-combobox-root')
|
||||
@@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import ProjectCombobox from '@/components/new-workspace/ProjectCombobox'
|
||||
import RunTargetCombobox from '@/components/new-workspace/RunTargetCombobox'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
EphemeralVmRecipeOption,
|
||||
NeedsProjectHostOption,
|
||||
@@ -85,91 +86,94 @@ export function NewWorkspaceComposerProjectSection({
|
||||
}: NewWorkspaceComposerProjectSectionProps): React.JSX.Element {
|
||||
return (
|
||||
<fieldset disabled={disabled} className="space-y-1">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{projectLabel ??
|
||||
translate('auto.components.NewWorkspaceComposerCard.969a8bff66', 'Project')}
|
||||
</label>
|
||||
{showAddProjectButton ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onAddProject}
|
||||
className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
|
||||
aria-label={translate(
|
||||
'auto.components.NewWorkspaceComposerCard.d6b0a96f32',
|
||||
'Add project'
|
||||
<div className={cn('grid gap-3', shouldShowRunTargetPicker && 'grid-cols-2')}>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex h-5 items-center justify-between gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{projectLabel ??
|
||||
translate('auto.components.NewWorkspaceComposerCard.969a8bff66', 'Project')}
|
||||
</label>
|
||||
{showAddProjectButton ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onAddProject}
|
||||
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>
|
||||
<div className="space-y-1" data-contextual-tour-target="workspace-creation-project">
|
||||
<ProjectCombobox
|
||||
options={projectOptions}
|
||||
value={selectedProjectId}
|
||||
onValueChange={onProjectChange}
|
||||
onValueSelected={focusNameInput}
|
||||
onAddProject={onAddProject}
|
||||
placeholder={
|
||||
projectPlaceholder ??
|
||||
translate('auto.components.NewWorkspaceComposerCard.dccd26d4e4', 'Choose project')
|
||||
}
|
||||
triggerClassName="h-9 w-full border-input text-sm focus:border-ring focus:ring-[3px] focus:ring-ring/50"
|
||||
invalid={Boolean(projectError)}
|
||||
describedBy={projectDescriptionId}
|
||||
/>
|
||||
{projectError ? (
|
||||
<p id={projectDescriptionId} className="text-[11px] text-destructive">
|
||||
{projectError}
|
||||
</p>
|
||||
) : projectOptions.length === 0 ? (
|
||||
<p id={projectDescriptionId} className="text-[11px] text-muted-foreground">
|
||||
{emptyProjectMessage ??
|
||||
translate(
|
||||
'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace',
|
||||
'Add a project before creating a workspace.'
|
||||
)}
|
||||
>
|
||||
<FolderPlus className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
{translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1" data-contextual-tour-target="workspace-creation-project">
|
||||
<ProjectCombobox
|
||||
options={projectOptions}
|
||||
value={selectedProjectId}
|
||||
onValueChange={onProjectChange}
|
||||
onValueSelected={focusNameInput}
|
||||
onAddProject={onAddProject}
|
||||
placeholder={
|
||||
projectPlaceholder ??
|
||||
translate('auto.components.NewWorkspaceComposerCard.dccd26d4e4', 'Choose project')
|
||||
}
|
||||
triggerClassName="h-9 w-full border-input text-sm focus:border-ring focus:ring-[3px] focus:ring-ring/50"
|
||||
invalid={Boolean(projectError)}
|
||||
describedBy={projectDescriptionId}
|
||||
/>
|
||||
{projectError ? (
|
||||
<p id={projectDescriptionId} className="text-[11px] text-destructive">
|
||||
{projectError}
|
||||
</p>
|
||||
) : projectOptions.length === 0 ? (
|
||||
<p id={projectDescriptionId} className="text-[11px] text-muted-foreground">
|
||||
{emptyProjectMessage ??
|
||||
translate(
|
||||
'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace',
|
||||
'Add a project before creating a workspace.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{shouldShowRunTargetPicker ? (
|
||||
<div className="min-w-0 space-y-1">
|
||||
<label className="flex h-5 min-w-0 items-center truncate text-xs font-medium text-muted-foreground">
|
||||
{translate('auto.components.NewWorkspaceComposerCard.runOn', 'Run on')}
|
||||
</label>
|
||||
<RunTargetCombobox
|
||||
hostOptions={projectHostSetupOptions ?? []}
|
||||
hostValue={selectedProjectHostSetupId ?? null}
|
||||
hostExecutionHostId={selectedRepoExecutionHostId}
|
||||
onHostChange={handleProjectHostSetupChange}
|
||||
recipes={ephemeralVmRecipes}
|
||||
recipeValue={selectedEphemeralVmRecipeId}
|
||||
onRecipeChange={onEphemeralVmRecipeChange}
|
||||
onAddSshHost={handleAddSshHost}
|
||||
onAddRemoteServer={handleAddRemoteServer}
|
||||
onConnectHost={handleConnectRunTargetHost}
|
||||
onSetLocation={handleSetLocation}
|
||||
/>
|
||||
{ephemeralVmRecipeError ? (
|
||||
<p className="whitespace-pre-line text-[11px] text-destructive">
|
||||
{ephemeralVmRecipeError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{shouldShowRunTargetPicker ? (
|
||||
<div className="space-y-1 pt-3">
|
||||
<label className="block min-w-0 truncate text-xs font-medium text-muted-foreground">
|
||||
{translate('auto.components.NewWorkspaceComposerCard.runOn', 'Run on')}
|
||||
</label>
|
||||
<RunTargetCombobox
|
||||
hostOptions={projectHostSetupOptions ?? []}
|
||||
hostValue={selectedProjectHostSetupId ?? null}
|
||||
hostExecutionHostId={selectedRepoExecutionHostId}
|
||||
onHostChange={handleProjectHostSetupChange}
|
||||
recipes={ephemeralVmRecipes}
|
||||
recipeValue={selectedEphemeralVmRecipeId}
|
||||
onRecipeChange={onEphemeralVmRecipeChange}
|
||||
onAddSshHost={handleAddSshHost}
|
||||
onAddRemoteServer={handleAddRemoteServer}
|
||||
onConnectHost={handleConnectRunTargetHost}
|
||||
onSetLocation={handleSetLocation}
|
||||
/>
|
||||
{ephemeralVmRecipeError ? (
|
||||
<p className="whitespace-pre-line text-[11px] text-destructive">
|
||||
{ephemeralVmRecipeError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : ephemeralVmRecipeError ? (
|
||||
<p className="whitespace-pre-line text-[11px] text-destructive">{ephemeralVmRecipeError}</p>
|
||||
{!shouldShowRunTargetPicker && ephemeralVmRecipeError ? (
|
||||
<p className="whitespace-pre-line text-2xs text-destructive">{ephemeralVmRecipeError}</p>
|
||||
) : null}
|
||||
{selectedRepoRequiresConnection && selectedRepoConnectionId ? (
|
||||
<div
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
import ProjectCombobox from './ProjectCombobox'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
vi.mock('./use-recent-project-ids', () => ({ useRecentProjectIds: () => [] }))
|
||||
|
||||
@@ -101,7 +102,13 @@ describe.each([false, true])(
|
||||
it.each(['mouse', 'keyboard'] as const)(
|
||||
'removes the selector before the creation dialog is active via %s',
|
||||
async (input) => {
|
||||
await act(async () => root.render(<Composer populated={populated} />))
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<Composer populated={populated} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
)
|
||||
await act(async () => projectField().click())
|
||||
expect(document.querySelector('[role="listbox"]')).not.toBeNull()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
|
||||
import ProjectCombobox from './ProjectCombobox'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
// Render inline and expose Radix-style close independently of the component key handler.
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
@@ -29,6 +30,10 @@ vi.mock('./use-recent-project-ids', () => ({ useRecentProjectIds: () => [] }))
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
function renderField(element: React.ReactNode): void {
|
||||
root.render(<TooltipProvider>{element}</TooltipProvider>)
|
||||
}
|
||||
|
||||
const projects: NewWorkspaceProjectOption[] = [
|
||||
{
|
||||
kind: 'project',
|
||||
@@ -109,7 +114,7 @@ afterEach(() => {
|
||||
describe('ProjectCombobox', () => {
|
||||
it('renders a logical project label without host-specific SSH chrome', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox options={projects} value="github:stablyai/orca" onValueChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
@@ -121,7 +126,7 @@ describe('ProjectCombobox', () => {
|
||||
|
||||
it('keeps a focusable combobox that composer focus helpers can target', () => {
|
||||
act(() => {
|
||||
root.render(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
|
||||
renderField(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
|
||||
})
|
||||
|
||||
const trigger = container.querySelector<HTMLElement>(
|
||||
@@ -138,7 +143,7 @@ describe('ProjectCombobox', () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox
|
||||
options={projects}
|
||||
value="github:stablyai/orca"
|
||||
@@ -158,7 +163,7 @@ describe('ProjectCombobox', () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox
|
||||
options={projects}
|
||||
value="project-group:folder-group"
|
||||
@@ -183,7 +188,7 @@ describe('ProjectCombobox', () => {
|
||||
const onAddProject = vi.fn()
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox
|
||||
options={[]}
|
||||
value={null}
|
||||
@@ -210,7 +215,7 @@ describe('ProjectCombobox', () => {
|
||||
const onAddProject = vi.fn()
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox
|
||||
options={projects}
|
||||
value={null}
|
||||
@@ -236,7 +241,7 @@ describe('ProjectCombobox', () => {
|
||||
|
||||
it('omits the "Add project" action when no handler is provided', () => {
|
||||
act(() => {
|
||||
root.render(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
|
||||
renderField(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
|
||||
})
|
||||
openList()
|
||||
|
||||
@@ -264,7 +269,7 @@ describe('ProjectCombobox', () => {
|
||||
]
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox options={duplicateProjects} value={null} onValueChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
@@ -276,7 +281,7 @@ describe('ProjectCombobox', () => {
|
||||
|
||||
it('filters as the user types, without a second search box', () => {
|
||||
act(() => {
|
||||
root.render(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
|
||||
renderField(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
|
||||
})
|
||||
openList()
|
||||
expect(container.querySelectorAll('input')).toHaveLength(1)
|
||||
@@ -291,7 +296,7 @@ describe('ProjectCombobox', () => {
|
||||
const onValueSelected = vi.fn()
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox
|
||||
options={projects}
|
||||
value={null}
|
||||
@@ -315,7 +320,7 @@ describe('ProjectCombobox', () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox
|
||||
options={projects}
|
||||
value={null}
|
||||
@@ -336,7 +341,7 @@ describe('ProjectCombobox', () => {
|
||||
|
||||
it('restores the committed project on Escape instead of stranding a stale query', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox options={projects} value="github:stablyai/orca" onValueChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
@@ -361,7 +366,7 @@ describe('ProjectCombobox', () => {
|
||||
|
||||
it('drops an uncommitted query when the list closes, so junk text never persists', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox options={projects} value="github:stablyai/orca" onValueChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
@@ -382,7 +387,7 @@ describe('ProjectCombobox', () => {
|
||||
|
||||
it('marks the field invalid so a failed create press can turn it red', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox
|
||||
options={projects}
|
||||
value={null}
|
||||
@@ -421,7 +426,7 @@ describe('ProjectCombobox', () => {
|
||||
|
||||
it('arms the first rendered row when sections reorder the list', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox options={sectionedOptions} value={null} onValueChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
@@ -437,7 +442,7 @@ describe('ProjectCombobox', () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox options={sectionedOptions} value={null} onValueChange={onValueChange} />
|
||||
)
|
||||
})
|
||||
@@ -456,7 +461,7 @@ describe('ProjectCombobox', () => {
|
||||
|
||||
it('steps ArrowDown to the next rendered row instead of jumping across sections', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox options={sectionedOptions} value={null} onValueChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
@@ -471,7 +476,7 @@ describe('ProjectCombobox', () => {
|
||||
|
||||
it('owns every option from the listbox, with no unroled wrapper in between', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
renderField(
|
||||
<ProjectCombobox
|
||||
options={projects}
|
||||
value={null}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ImeInput } from '@/lib/ime-text-field'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { ChevronDown, FolderPlus } from 'lucide-react'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
rankProjectOptions,
|
||||
sectionProjectOptions
|
||||
} from './project-combobox-matching'
|
||||
import { ProjectOptionDetail, ProjectOptionMark, ProjectOptionRow } from './ProjectComboboxRow'
|
||||
import { ProjectOptionMark, ProjectOptionRow } from './ProjectComboboxRow'
|
||||
import { useRecentProjectIds } from './use-recent-project-ids'
|
||||
import { isWithinComboboxRoot, useTypeAheadCombobox } from './use-type-ahead-combobox'
|
||||
import { COMBOBOX_FIELD_SHELL, COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles'
|
||||
@@ -42,6 +43,7 @@ export default function ProjectCombobox({
|
||||
invalid = false,
|
||||
describedBy
|
||||
}: ProjectComboboxProps): React.JSX.Element {
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false)
|
||||
const recentIds = useRecentProjectIds()
|
||||
// Derive keys from the displayed sections: folder grouping changes raw rank order, so keyboard selection must follow what users see.
|
||||
const deriveRowKeys = useCallback(
|
||||
@@ -129,89 +131,91 @@ export default function ProjectCombobox({
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
data-project-combobox-root="true"
|
||||
onClick={() => {
|
||||
inputRef.current?.focus()
|
||||
setOpen(true)
|
||||
}}
|
||||
className={cn(
|
||||
COMBOBOX_FIELD_SHELL,
|
||||
invalid && 'border-destructive ring-destructive/20 dark:ring-destructive/40',
|
||||
triggerClassName
|
||||
)}
|
||||
>
|
||||
<span className="flex w-4 shrink-0 items-center justify-center">
|
||||
{committed && selected ? <ProjectOptionMark option={selected} /> : null}
|
||||
</span>
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||
<ImeInput
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
<Tooltip open={tooltipOpen && committed && !open} onOpenChange={setTooltipOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
data-project-combobox-root="true"
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.label',
|
||||
'Project'
|
||||
)}
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={open && armedKey ? `${listId}-armed` : undefined}
|
||||
aria-invalid={invalid ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
value={query}
|
||||
placeholder={committed ? '' : placeholder}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value)
|
||||
onClick={() => {
|
||||
inputRef.current?.focus()
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
|
||||
committed && 'text-transparent caret-foreground'
|
||||
COMBOBOX_FIELD_SHELL,
|
||||
invalid && 'border-destructive ring-destructive/20 dark:ring-destructive/40',
|
||||
triggerClassName
|
||||
)}
|
||||
/>
|
||||
{/* Overlay name/path separately so they can shrink at different rates. */}
|
||||
{committed && selected ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center text-sm"
|
||||
>
|
||||
{/* Baseline-align mixed text sizes; centering would lift the smaller detail. */}
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="min-w-0 max-w-[50%] shrink truncate">
|
||||
{selected.displayName}
|
||||
</span>
|
||||
<ProjectOptionDetail
|
||||
detail={selected.detail}
|
||||
className="min-w-0 flex-1 shrink-[999] justify-end text-right text-xs text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<span className="flex w-4 shrink-0 items-center justify-center">
|
||||
{committed && selected ? <ProjectOptionMark option={selected} /> : null}
|
||||
</span>
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||
<ImeInput
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
data-project-combobox-root="true"
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.label',
|
||||
'Project'
|
||||
)}
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={open && armedKey ? `${listId}-armed` : undefined}
|
||||
aria-invalid={invalid ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
value={query}
|
||||
placeholder={committed ? '' : placeholder}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
|
||||
committed && 'text-transparent caret-foreground'
|
||||
)}
|
||||
/>
|
||||
{committed && selected ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex min-w-0 items-center text-sm"
|
||||
>
|
||||
<span className="truncate">{selected.displayName}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.browse',
|
||||
'Browse projects'
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
inputRef.current?.focus()
|
||||
setOpen(!open)
|
||||
}}
|
||||
className="-mr-1 flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.browse',
|
||||
'Browse projects'
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
inputRef.current?.focus()
|
||||
setOpen(!open)
|
||||
}}
|
||||
className="-mr-1 flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('size-3.5 transition-transform', open && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</PopoverAnchor>
|
||||
{committed && selected ? (
|
||||
<TooltipContent side="top" sideOffset={4} className="max-w-96 break-all">
|
||||
<div className="font-medium">{selected.displayName}</div>
|
||||
<div>{selected.detail}</div>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
{/* Closing must remove the selector before a nested dialog takes focus. */}
|
||||
{open ? (
|
||||
<PopoverContent
|
||||
@@ -305,11 +309,13 @@ export default function ProjectCombobox({
|
||||
onMouseMove={() => arm(ADD_PROJECT_KEY)}
|
||||
onClick={() => commit(ADD_PROJECT_KEY)}
|
||||
className={cn(
|
||||
'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm',
|
||||
'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-3 text-sm',
|
||||
armedKey === ADD_PROJECT_KEY && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<FolderPlus className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex w-4 shrink-0 items-center justify-center">
|
||||
<FolderPlus className="size-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.addProject',
|
||||
|
||||
@@ -118,29 +118,26 @@ export function ProjectOptionRow({
|
||||
onMouseMove={onArm}
|
||||
onClick={onCommit}
|
||||
className={cn(
|
||||
// Baseline-align mixed text sizes so name/detail read as one line.
|
||||
'flex h-8 cursor-default items-baseline gap-2 rounded-sm px-2 text-sm',
|
||||
'flex cursor-default items-start gap-2 rounded-sm px-2 py-1.5 text-sm',
|
||||
armed && 'bg-accent text-accent-foreground',
|
||||
current && !armed && 'bg-accent/60'
|
||||
)}
|
||||
>
|
||||
<span className="flex h-8 shrink-0 items-center">
|
||||
<span className="flex h-5 w-4 shrink-0 items-center justify-center">
|
||||
<ProjectOptionMark option={option} />
|
||||
</span>
|
||||
{/* Reserve up to half the row for the name so deep paths cannot squeeze it away. */}
|
||||
<MatchedText
|
||||
text={option.displayName}
|
||||
hits={nameHits}
|
||||
className={cn('max-w-[50%] shrink', current && 'font-medium')}
|
||||
/>
|
||||
<ProjectOptionDetail
|
||||
detail={option.detail}
|
||||
hits={detailHits}
|
||||
className={cn(
|
||||
'ml-auto min-w-0 flex-1 shrink-[999] justify-end pl-2 text-right text-xs',
|
||||
ambiguous ? 'text-foreground/80' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<MatchedText
|
||||
text={option.displayName}
|
||||
hits={nameHits}
|
||||
className={current ? 'font-medium' : undefined}
|
||||
/>
|
||||
<ProjectOptionDetail
|
||||
detail={option.detail}
|
||||
hits={detailHits}
|
||||
className={cn('text-xs', ambiguous ? 'text-foreground/80' : 'text-muted-foreground')}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React from 'react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render as renderUi, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReadyProjectHostSetupOption } from '@/lib/project-host-setup-options'
|
||||
import RunTargetCombobox from './RunTargetCombobox'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { buildRunTargetRows } from './run-target-options'
|
||||
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
@@ -13,6 +14,10 @@ vi.mock('@/components/ui/popover', () => ({
|
||||
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
function render(element: React.ReactNode): ReturnType<typeof renderUi> {
|
||||
return renderUi(<TooltipProvider>{element}</TooltipProvider>)
|
||||
}
|
||||
|
||||
const localOption: ReadyProjectHostSetupOption = {
|
||||
id: 'same-id',
|
||||
kind: 'ready',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { HostRowIcon } from '../host-row-icon'
|
||||
|
||||
export { HostRowIcon }
|
||||
|
||||
/** Match project-picker baseline rows; stacked Add-host cards add explanatory descriptions. */
|
||||
/** Match the project picker with details below each target name. */
|
||||
export function RunTargetRow({
|
||||
icon,
|
||||
label,
|
||||
@@ -16,7 +16,7 @@ export function RunTargetRow({
|
||||
optionId,
|
||||
dimmed = false,
|
||||
submenu = false,
|
||||
stacked = false,
|
||||
stacked = true,
|
||||
onArm,
|
||||
onCommit,
|
||||
trailing
|
||||
@@ -58,17 +58,20 @@ export function RunTargetRow({
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex shrink-0 items-center',
|
||||
stacked ? 'self-start pt-0.5' : 'h-8',
|
||||
'flex w-4 shrink-0 items-center justify-center',
|
||||
stacked ? 'h-5' : 'h-8',
|
||||
stacked && detail && 'self-start',
|
||||
dimmed && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{stacked ? (
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className={cn('flex min-w-0 flex-1 flex-col', dimmed && 'opacity-60')}>
|
||||
<span className={cn('truncate', current && 'font-medium')}>{label}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{detail}</span>
|
||||
{detail ? (
|
||||
<ProjectOptionDetail detail={detail} className="text-xs text-muted-foreground" />
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { ImeInput } from '@/lib/ime-text-field'
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronDown, Cloud } from 'lucide-react'
|
||||
import { PopoverAnchor } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { HostRowIcon } from './RunTargetComboboxRow'
|
||||
import { COMBOBOX_FIELD_SHELL } from './type-ahead-combobox-styles'
|
||||
|
||||
/** Overlay committed label/path on the search input so they can shrink independently. */
|
||||
/** Overlay the committed selection while keeping the input ready to search. */
|
||||
export default function RunTargetField({
|
||||
query,
|
||||
onQueryChange,
|
||||
@@ -40,92 +41,94 @@ export default function RunTargetField({
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
}): React.JSX.Element {
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false)
|
||||
return (
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
data-run-target-combobox-root="true"
|
||||
onClick={() => {
|
||||
inputRef.current?.focus()
|
||||
onOpenRequest()
|
||||
}}
|
||||
className={COMBOBOX_FIELD_SHELL}
|
||||
>
|
||||
<span className="flex w-4 shrink-0 items-center justify-center">
|
||||
{committed ? (
|
||||
isRecipe ? (
|
||||
<Cloud className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : hostId ? (
|
||||
<HostRowIcon hostId={hostId} />
|
||||
) : null
|
||||
) : null}
|
||||
</span>
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||
<ImeInput
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
<Tooltip open={tooltipOpen && committed && !open} onOpenChange={setTooltipOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
data-run-target-combobox-root="true"
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.RunTargetCombobox.label',
|
||||
'Run on'
|
||||
)}
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={open && hasArmedRow ? `${listId}-armed` : undefined}
|
||||
value={query}
|
||||
placeholder={
|
||||
committed
|
||||
? ''
|
||||
: translate(
|
||||
'auto.components.NewWorkspaceComposerCard.chooseRunTarget',
|
||||
'Choose target'
|
||||
)
|
||||
}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onFocus={onOpenRequest}
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn(
|
||||
'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
|
||||
committed && 'text-transparent caret-foreground'
|
||||
)}
|
||||
/>
|
||||
{committed ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center text-sm"
|
||||
>
|
||||
{/* Baseline-aligned: centring two type sizes leaves the smaller high. */}
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="min-w-0 max-w-[50%] shrink truncate">{label}</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 shrink-[999] truncate text-right text-xs text-muted-foreground"
|
||||
title={detail}
|
||||
onClick={() => {
|
||||
inputRef.current?.focus()
|
||||
onOpenRequest()
|
||||
}}
|
||||
className={COMBOBOX_FIELD_SHELL}
|
||||
>
|
||||
<span className="flex w-4 shrink-0 items-center justify-center">
|
||||
{committed ? (
|
||||
isRecipe ? (
|
||||
<Cloud className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : hostId ? (
|
||||
<HostRowIcon hostId={hostId} />
|
||||
) : null
|
||||
) : null}
|
||||
</span>
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||
<ImeInput
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
data-run-target-combobox-root="true"
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.RunTargetCombobox.label',
|
||||
'Run on'
|
||||
)}
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={open && hasArmedRow ? `${listId}-armed` : undefined}
|
||||
value={query}
|
||||
placeholder={
|
||||
committed
|
||||
? ''
|
||||
: translate(
|
||||
'auto.components.NewWorkspaceComposerCard.chooseRunTarget',
|
||||
'Choose target'
|
||||
)
|
||||
}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onFocus={onOpenRequest}
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn(
|
||||
'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
|
||||
committed && 'text-transparent caret-foreground'
|
||||
)}
|
||||
/>
|
||||
{committed ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center text-sm"
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
</div>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.RunTargetCombobox.browse',
|
||||
'Browse run targets'
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
inputRef.current?.focus()
|
||||
onToggle()
|
||||
}}
|
||||
className="-mr-1 flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.RunTargetCombobox.browse',
|
||||
'Browse run targets'
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
inputRef.current?.focus()
|
||||
onToggle()
|
||||
}}
|
||||
className="-mr-1 flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</PopoverAnchor>
|
||||
{committed ? (
|
||||
<TooltipContent side="top" sideOffset={4} className="max-w-96 break-all">
|
||||
<div className="font-medium">{label}</div>
|
||||
<div>{detail}</div>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,11 +122,13 @@ export function AddHostSubmenuRow({
|
||||
onMouseMove={onArm}
|
||||
onClick={() => onOpenChange(true)}
|
||||
className={cn(
|
||||
'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm',
|
||||
'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-3 text-sm',
|
||||
armed && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<Plus className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex w-4 shrink-0 items-center justify-center">
|
||||
<Plus className="size-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="truncate">{addHostLabel}</span>
|
||||
<span className="ml-auto flex shrink-0 items-center">
|
||||
<ChevronDown className="size-3.5 -rotate-90 text-muted-foreground" />
|
||||
|
||||
Reference in New Issue
Block a user