diff --git a/notes/project-first-host-model-change-inventory.md b/notes/project-first-host-model-change-inventory.md index 9a9fb7222b1..8edaec19fa8 100644 --- a/notes/project-first-host-model-change-inventory.md +++ b/notes/project-first-host-model-change-inventory.md @@ -316,8 +316,9 @@ Needs: Current branch status: partially implemented. Ready project-host setups can be selected from the composer `Run on` menu, and known hosts that still need setup -now appear as disabled rows instead of being invisible. Inline setup actions for -those rows are still missing. +now appear as setup-target rows instead of being invisible. The composer can +import an existing folder for those hosts and then switch to the new ready +setup. Inline clone/provision actions for those rows are still missing. ### 5. Project Setup Flow @@ -474,13 +475,15 @@ Already partially landed: - CLI commands for listing projects/setups and creating worktrees by project/setup - composer `Run on` selector when a project has multiple ready setups -- disabled `Run on` rows for known hosts where the selected project is not set - up yet +- setup-target `Run on` rows for known hosts where the selected project is not + set up yet +- inline `Run on` import-existing-folder setup for known hosts where the + selected project is not set up yet Not complete yet: - clone/provision flows -- inline setup actions from the composer `Run on` menu +- inline clone/provision actions from the composer `Run on` menu - bulk setup flows and setup for hosts that are not already known to the client - project settings split into global and host-specific ownership - host settings/capability UI aligned with project setup diff --git a/notes/project-first-host-model-discussion.md b/notes/project-first-host-model-discussion.md index deb9cfa023e..fa79a71b875 100644 --- a/notes/project-first-host-model-discussion.md +++ b/notes/project-first-host-model-discussion.md @@ -356,8 +356,9 @@ Needs: Current branch status: partially implemented. Ready project-host setups can be selected from the composer `Run on` menu, and known hosts that still need setup -now appear as disabled rows instead of being invisible. Inline setup actions for -those rows are still missing. +now appear as setup-target rows instead of being invisible. The composer can +import an existing folder for those hosts and then switch to the new ready +setup. Inline clone/provision actions for those rows are still missing. ### 5. Project Setup Flow @@ -513,13 +514,15 @@ Already partially landed: - CLI commands for listing projects/setups and creating worktrees by project/setup - composer `Run on` selector when a project has multiple ready setups -- disabled `Run on` rows for known hosts where the selected project is not set - up yet +- setup-target `Run on` rows for known hosts where the selected project is not + set up yet +- inline `Run on` import-existing-folder setup for known hosts where the + selected project is not set up yet Not complete yet: - clone/provision flows -- inline setup actions from the composer `Run on` menu +- inline clone/provision actions from the composer `Run on` menu - bulk setup flows and setup for hosts that are not already known to the client - full project settings split into global and host-specific ownership - host settings/capability UI aligned with project setup diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index d054b85fb97..2e143b1ed3f 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -747,6 +747,9 @@ Landed so far: disabled "project not set up on this host" rows, not only as invisible missing options. Ready setups remain selectable and still resolve through the compatibility repo path. +- Added an inline composer action for those not-yet-set-up hosts to import an + existing folder on that host. After import, the composer switches to the new + ready setup so workspace creation can continue on that host. - Added tests for local repos, SSH repos, same-provider multi-host grouping, no-identity same-name non-grouping, selector cache behavior, persistence backfill, repo mutation synchronization, renderer hydration, and runtime RPC @@ -768,7 +771,7 @@ Important limitation: Remaining end-to-end work: -- add inline clone/import actions for `Run on` hosts where the project is not +- add inline clone/provision actions for `Run on` hosts where the project is not set up yet - broaden setup-on-host flows beyond known local, SSH, and active runtime hosts - add clone/provision setup-on-host flows diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 11822012f3f..6477ed4bcec 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -13,6 +13,14 @@ import { Settings2 } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import RepoCombobox from '@/components/repo/RepoCombobox' import AgentCombobox from '@/components/agent/AgentCombobox' @@ -36,7 +44,10 @@ import SmartWorkspaceNameField, { } from '@/components/new-workspace/SmartWorkspaceNameField' import ProjectHostSetupCombobox from '@/components/new-workspace/ProjectHostSetupCombobox' import type { SetupConfig } from '@/lib/new-workspace' -import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' +import type { + NeedsSetupProjectHostOption, + ProjectHostSetupOption +} from '@/lib/project-host-setup-options' import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format' import type { SshConnectionStatus } from '../../../shared/ssh-types' import { translate } from '@/i18n/i18n' @@ -58,6 +69,11 @@ type NewWorkspaceComposerCardProps = { projectHostSetupOptions: ProjectHostSetupOption[] selectedProjectHostSetupId: string | null onProjectHostSetupChange: (setupId: string) => void + onProjectHostExistingFolderSetup: ( + option: NeedsSetupProjectHostOption, + path: string, + kind: 'git' | 'folder' + ) => Promise primaryActionLabel: string name: string onNameValueChange: (value: string) => void @@ -241,6 +257,7 @@ export default function NewWorkspaceComposerCard({ projectHostSetupOptions, selectedProjectHostSetupId, onProjectHostSetupChange, + onProjectHostExistingFolderSetup, primaryActionLabel, name, onNameValueChange, @@ -279,6 +296,12 @@ export default function NewWorkspaceComposerCard({ onSparseSelectPreset }: NewWorkspaceComposerCardProps): React.JSX.Element { const { isFileDragOver, dragHandlers } = useComposerFileDragOver() + const [pendingSetupHostOptionId, setPendingSetupHostOptionId] = React.useState( + null + ) + const [pendingSetupPath, setPendingSetupPath] = React.useState('') + const [pendingSetupKind, setPendingSetupKind] = React.useState<'git' | 'folder'>('git') + const [pendingSetupImporting, setPendingSetupImporting] = React.useState(false) const openModal = useAppStore((s) => s.openModal) const activeModal = useAppStore((s) => s.activeModal) const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) @@ -380,6 +403,44 @@ export default function NewWorkspaceComposerCard({ openModal('add-repo') }, [openModal]) const projectDescriptionId = React.useId() + const pendingSetupHostOption = React.useMemo( + () => + projectHostSetupOptions.find( + (option): option is NeedsSetupProjectHostOption => + option.kind === 'needs-setup' && option.id === pendingSetupHostOptionId + ) ?? null, + [pendingSetupHostOptionId, projectHostSetupOptions] + ) + const handleProjectHostSetupChange = React.useCallback( + (setupId: string): void => { + setPendingSetupHostOptionId(null) + onProjectHostSetupChange(setupId) + }, + [onProjectHostSetupChange] + ) + const handleNeedsSetupHostSelect = React.useCallback((option: NeedsSetupProjectHostOption) => { + setPendingSetupHostOptionId(option.id) + }, []) + const handleImportExistingFolder = React.useCallback(async (): Promise => { + if (!pendingSetupHostOption || !pendingSetupPath.trim()) { + return + } + setPendingSetupImporting(true) + try { + const imported = await onProjectHostExistingFolderSetup( + pendingSetupHostOption, + pendingSetupPath.trim(), + pendingSetupKind + ) + if (imported) { + setPendingSetupHostOptionId(null) + setPendingSetupPath('') + setPendingSetupKind('git') + } + } finally { + setPendingSetupImporting(false) + } + }, [onProjectHostExistingFolderSetup, pendingSetupHostOption, pendingSetupKind, pendingSetupPath]) useContextualTour( 'workspace-creation', eligibleRepos.length > 0 && Boolean(repoId), @@ -473,8 +534,78 @@ export default function NewWorkspaceComposerCard({ + {pendingSetupHostOption ? ( +
+
+ {translate( + 'auto.components.NewWorkspaceComposerCard.setupHostExistingFolderTitle', + 'Import existing folder on {{value0}}', + { value0: pendingSetupHostOption.label } + )} +
+
+ setPendingSetupPath(event.target.value)} + placeholder={translate( + 'auto.components.NewWorkspaceComposerCard.setupHostExistingFolderPlaceholder', + '/path/to/project/on/host' + )} + className="h-8 min-w-0 text-xs" + /> + +
+
+

+ {translate( + 'auto.components.NewWorkspaceComposerCard.setupHostExistingFolderHelp', + 'Link a checkout that already exists there, then create this workspace on that host.' + )} +

+ +
+
+ ) : null} ) : null} {selectedRepoRequiresConnection && selectedRepoConnectionId ? ( diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx new file mode 100644 index 00000000000..1b9a9ba66af --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + NeedsSetupProjectHostOption, + ProjectHostSetupOption +} from '@/lib/project-host-setup-options' +import ProjectHostSetupCombobox from './ProjectHostSetupCombobox' + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, + PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/command', () => ({ + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandEmpty: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandList: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandItem: ({ + children, + onSelect, + value + }: { + children: React.ReactNode + onSelect?: (value: string) => void + value: string + }) => ( + + ) +})) + +let container: HTMLDivElement +let root: Root + +const readyOption: ProjectHostSetupOption = { + id: 'local-setup', + kind: 'ready', + projectId: 'project-1', + hostId: 'local', + repoId: 'local-repo', + label: 'Local Mac', + detail: 'Orca', + path: '/Users/alice/orca' +} + +const needsSetupOption: NeedsSetupProjectHostOption = { + id: 'needs-setup:ssh:builder', + kind: 'needs-setup', + projectId: 'project-1', + hostId: 'ssh:builder', + label: 'Builder', + detail: 'Project not set up on this host' +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +function renderCombobox({ + onValueChange = vi.fn(), + onNeedsSetupHostSelect = vi.fn() +}: { + onValueChange?: (setupId: string) => void + onNeedsSetupHostSelect?: (option: NeedsSetupProjectHostOption) => void +} = {}): void { + act(() => { + root.render( + + ) + }) +} + +describe('ProjectHostSetupCombobox', () => { + it('routes ready setup rows through onValueChange', () => { + const onValueChange = vi.fn() + const onNeedsSetupHostSelect = vi.fn() + + renderCombobox({ onValueChange, onNeedsSetupHostSelect }) + + act(() => { + container + .querySelector('[data-command-value="local-setup"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onValueChange).toHaveBeenCalledWith('local-setup') + expect(onNeedsSetupHostSelect).not.toHaveBeenCalled() + }) + + it('routes hosts that need setup through onNeedsSetupHostSelect', () => { + const onValueChange = vi.fn() + const onNeedsSetupHostSelect = vi.fn() + + renderCombobox({ onValueChange, onNeedsSetupHostSelect }) + + act(() => { + container + .querySelector('[data-command-value="needs-setup:ssh:builder"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onValueChange).not.toHaveBeenCalled() + expect(onNeedsSetupHostSelect).toHaveBeenCalledWith(needsSetupOption) + }) +}) diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx index 02e27e98b7c..fcfac39a209 100644 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx +++ b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx @@ -4,19 +4,24 @@ import { Button } from '@/components/ui/button' import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' -import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' +import type { + NeedsSetupProjectHostOption, + ProjectHostSetupOption +} from '@/lib/project-host-setup-options' import { translate } from '@/i18n/i18n' type ProjectHostSetupComboboxProps = { options: readonly ProjectHostSetupOption[] value: string | null onValueChange: (setupId: string) => void + onNeedsSetupHostSelect?: (option: NeedsSetupProjectHostOption) => void } export default function ProjectHostSetupCombobox({ options, value, - onValueChange + onValueChange, + onNeedsSetupHostSelect }: ProjectHostSetupComboboxProps): React.JSX.Element { const [open, setOpen] = React.useState(false) const readyOptions = options.filter((option) => option.kind === 'ready') @@ -24,13 +29,22 @@ export default function ProjectHostSetupCombobox({ const handleSelect = React.useCallback( (setupId: string): void => { - if (!readyOptions.some((option) => option.id === setupId)) { + const option = options.find((candidate) => candidate.id === setupId) + if (!option) { + return + } + if (option.kind === 'needs-setup') { + onNeedsSetupHostSelect?.(option) + setOpen(false) + return + } + if (!readyOptions.some((candidate) => candidate.id === setupId)) { return } onValueChange(setupId) setOpen(false) }, - [onValueChange, readyOptions] + [onNeedsSetupHostSelect, onValueChange, options, readyOptions] ) return ( @@ -76,7 +90,6 @@ export default function ProjectHostSetupCombobox({ key={option.id} value={option.id} onSelect={() => handleSelect(option.id)} - disabled={option.kind !== 'ready'} className="items-center gap-2 px-3 py-2" > void + onProjectHostExistingFolderSetup: ( + option: NeedsSetupProjectHostOption, + path: string, + kind: 'git' | 'folder' + ) => Promise name: string onNameValueChange: (value: string) => void onSmartGitHubItemSelect: (item: GitHubWorkItem) => void @@ -304,6 +310,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS closeModal: s.closeModal, openSettingsPage: s.openSettingsPage, openSettingsTarget: s.openSettingsTarget, + setupProjectExistingFolder: s.setupProjectExistingFolder, prefetchWorktreeCreateBase: s.prefetchWorktreeCreateBase, prefetchWorkItems: s.prefetchWorkItems, fetchSparsePresets: s.fetchSparsePresets @@ -318,6 +325,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS closeModal, openSettingsPage, openSettingsTarget, + setupProjectExistingFolder, prefetchWorktreeCreateBase, prefetchWorkItems, fetchSparsePresets @@ -1703,6 +1711,27 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }, [handleRepoChange, projectHostSetupOptions] ) + const handleProjectHostExistingFolderSetup = useCallback( + async ( + option: NeedsSetupProjectHostOption, + path: string, + kind: 'git' | 'folder' + ): Promise => { + const result = await setupProjectExistingFolder({ + projectId: option.projectId, + hostId: option.hostId, + path, + kind, + displayName: selectedRepo?.displayName + }) + if (!result) { + return false + } + handleRepoChange(result.repo.id) + return true + }, + [handleRepoChange, selectedRepo?.displayName, setupProjectExistingFolder] + ) const showProjectRequiredError = useCallback((): void => { setProjectError('Choose or add a project before creating a workspace.') @@ -2572,6 +2601,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS projectHostSetupOptions, selectedProjectHostSetupId, onProjectHostSetupChange: handleProjectHostSetupChange, + onProjectHostExistingFolderSetup: handleProjectHostExistingFolderSetup, name, onNameValueChange: handleNameValueChange, onSmartGitHubItemSelect: handleSmartGitHubItemSelect, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d7299a3d7d0..a43968d2cfb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -993,7 +993,14 @@ "7711ad5122": "Local setup command", "e5db1b0419": "Combined setup command", "runOn": "Run on", - "addProjectBeforeWorkspace": "Add a project before creating a workspace." + "addProjectBeforeWorkspace": "Add a project before creating a workspace.", + "setupHostExistingFolderTitle": "Import existing folder on {{value0}}", + "setupHostExistingFolderPlaceholder": "/path/to/project/on/host", + "setupKindGit": "Git repo", + "setupKindFolder": "Folder", + "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", + "importingHostSetup": "Importing...", + "importHostSetup": "Import" }, "NewWorkspaceComposerModal": { "fa90f739a5": "Choose the project, workspace name, and agent before creating the workspace." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 02e697c619c..67febad226a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -993,7 +993,14 @@ "7711ad5122": "ローカルセットアップコマンド", "e5db1b0419": "組み合わせセットアップコマンド", "runOn": "実行先", - "addProjectBeforeWorkspace": "ワークスペースを作成する前にプロジェクトを追加します。" + "addProjectBeforeWorkspace": "ワークスペースを作成する前にプロジェクトを追加します。", + "setupHostExistingFolderTitle": "Import existing folder on {{value0}}", + "setupHostExistingFolderPlaceholder": "/path/to/project/on/host", + "setupKindGit": "Git repo", + "setupKindFolder": "Folder", + "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", + "importingHostSetup": "Importing...", + "importHostSetup": "Import" }, "NewWorkspaceComposerModal": { "fa90f739a5": "ワークスペースを作成する前に、プロジェクト、ワークスペース名、およびエージェントを選択します。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4aca3f0c76a..3b9b0dc439d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -993,7 +993,14 @@ "7711ad5122": "로컬 설정 명령", "e5db1b0419": "결합된 설정 명령", "runOn": "실행 위치", - "addProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 추가하세요." + "addProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 추가하세요.", + "setupHostExistingFolderTitle": "Import existing folder on {{value0}}", + "setupHostExistingFolderPlaceholder": "/path/to/project/on/host", + "setupKindGit": "Git repo", + "setupKindFolder": "Folder", + "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", + "importingHostSetup": "Importing...", + "importHostSetup": "Import" }, "NewWorkspaceComposerModal": { "fa90f739a5": "워크스페이스를 생성하기 전에 프로젝트, 워크스페이스 이름, 에이전트를 선택하세요." diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5d22b07be6c..14198373adf 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -993,7 +993,14 @@ "7711ad5122": "本地设置命令", "e5db1b0419": "组合设置命令", "runOn": "运行位置", - "addProjectBeforeWorkspace": "在创建工作区之前添加项目。" + "addProjectBeforeWorkspace": "在创建工作区之前添加项目。", + "setupHostExistingFolderTitle": "Import existing folder on {{value0}}", + "setupHostExistingFolderPlaceholder": "/path/to/project/on/host", + "setupKindGit": "Git repo", + "setupKindFolder": "Folder", + "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", + "importingHostSetup": "Importing...", + "importHostSetup": "Import" }, "NewWorkspaceComposerModal": { "fa90f739a5": "创建工作区之前选择项目、工作区名称和代理。" diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts index f4f06929656..755d34eca43 100644 --- a/src/renderer/src/lib/project-host-setup-options.ts +++ b/src/renderer/src/lib/project-host-setup-options.ts @@ -28,7 +28,7 @@ export type ProjectHostSetupOption = export type ReadyProjectHostSetupOption = Extract -type NeedsSetupProjectHostOption = Extract +export type NeedsSetupProjectHostOption = Extract type BuildReadySetupOptionsInput = { projectId: string