diff --git a/notes/project-first-host-model-change-inventory.md b/notes/project-first-host-model-change-inventory.md index 8edaec19fa8..d925e5c4f57 100644 --- a/notes/project-first-host-model-change-inventory.md +++ b/notes/project-first-host-model-change-inventory.md @@ -317,8 +317,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 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. +import an existing folder for those hosts and can clone onto non-SSH +local/runtime hosts, then switch to the new ready setup. SSH clone and +provisioning actions are still missing. ### 5. Project Setup Flow @@ -335,7 +336,9 @@ Needs: Current branch status: existing-folder setup is partially implemented through API/CLI plumbing and a project settings import form for known local, SSH, and -active runtime hosts. Clone, provisioning, and bulk setup are not complete. +active runtime hosts. Composer clone setup is implemented for local/runtime +hosts by composing the existing clone APIs with existing-folder setup. SSH +clone, provisioning, and bulk setup are not complete. ### 6. Sidebar Row Model @@ -479,11 +482,12 @@ Already partially landed: set up yet - inline `Run on` import-existing-folder setup for known hosts where the selected project is not set up yet +- inline `Run on` clone setup for not-yet-set-up non-SSH local/runtime hosts Not complete yet: -- clone/provision flows -- inline clone/provision actions from the composer `Run on` menu +- SSH clone/provision flows +- durable cloned setup metadata while `Repo` remains the compatibility source - 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-handoff.md b/notes/project-first-host-model-handoff.md new file mode 100644 index 00000000000..65ace713f35 --- /dev/null +++ b/notes/project-first-host-model-handoff.md @@ -0,0 +1,429 @@ +# Project-First Host Model Handoff + +## What We Discussed + +We started from the multi-host / VM / SSH work that made machines visible in +the sidebar. That model is useful operationally: + +```text +Host + Project + Workspace +``` + +It answers questions like which host is online, where a workspace is running, +and whether an SSH target is disconnected. But as the default durable product +model, it makes Orca feel like the user is switching between machine silos. + +The direction we converged on is project-first: + +```text +Project + Host setup + Workspace +``` + +In plain English: a host is a place where a project can run. The host should not +usually be the object that owns the user's project. + +## Decision + +Move Orca toward this durable model: + +```text +Project -> ProjectHostSetup -> Workspace +``` + +Where: + +- `Project` is the durable repo/project identity the user recognizes. +- `Host` is a local Mac, SSH target, remote runtime, VM, remote server, or + future Orca-provisioned cloud VM. +- `ProjectHostSetup` means "this project is available on this host at this + path, with this setup state and host-specific settings." +- `Workspace` is a branch/task/worktree running from one project setup on one + host. + +This model handles local-only projects, remote-only projects, Linux-only +projects, GPU-only projects, work-machine-only projects, and future cloud VMs +without changing the user's primary mental model. + +## Superset Comparison + +Superset is the closer reference for Orca's desired data model. + +The relevant Superset shape is: + +```text +Project + Host -> Workspace +``` + +Lessons for Orca: + +- Project identity is durable. +- A host is where a project can be materialized. +- Workspace creation targets both a project and a host. +- The same project can be set up on multiple hosts. +- If the project is not available on a host, creation can block and offer setup. +- Project settings can include host-specific location and worktree settings. + +That maps well to Orca because Orca already has projects/repos, worktrees, +agents, terminals, source control, and host-aware runtime routing. + +## Cmux Comparison + +Cmux is more session/workspace-first than project-first. + +The relevant Cmux shape is closer to: + +```text +Workspace/session -> local or remote execution context +``` + +Lessons for Orca: + +- SSH should feel first class. +- Remote terminals, file views, browser panes, and localhost routing should + follow the remote execution context. +- Reconnect and persistence behavior matter. +- Cmux is a useful reference for SSH/session polish, not for Orca's durable + project/setup data model. + +## UX Direction + +### Sidebar + +The default long-term sidebar should be project-first. + +For a single-host project, avoid noisy host nesting: + +```text +Orca + feature-a + feature-b +``` + +For a multi-host project, show host context only where it helps: + +```text +Orca + Local Mac + feature-a + openclaw 2 + fix-ssh-agent-status +``` + +Host-first grouping can remain as a filter or operational view. It is valuable +for seeing online/offline state and troubleshooting, but it should not be the +only durable organization model. + +Disconnected host behavior should distinguish configured hosts from relevant +project hosts: + +- Show a disconnected host when it has setup history or workspaces for the + current project. +- Do not show a never-used SSH target in the project sidebar just because it is + configured. +- Use clear disconnected wording/actions instead of only a gray status dot. + +### Workspace Creation + +Creating a workspace should ask: + +1. Which project? +2. Which host should run it? +3. What branch/task/workspace name? + +If the project is not set up on the selected host, Orca should offer: + +- clone the project to that host +- import an existing folder on that host +- select a different host + +The user should not need to understand compatibility repo ids. + +### Project Setup + +"Add project" and "make this project available on another host" are related but +different actions. + +Important flows: + +- import a local folder as a new project +- import an SSH folder as a new project +- set up an existing project on another host +- clone an existing project onto a selected host +- when adding a new host, optionally initialize one or more projects there +- later, provision an Orca cloud VM and materialize selected projects there + +### Settings + +Settings need explicit ownership: + +| Setting type | Owner | Examples | +| --- | --- | --- | +| Client setting | desktop client | theme, local UI preferences | +| Host setting | machine/runtime | SSH connection, display name, health, server version | +| Project setting | durable project | project name, provider identity | +| Project-host setup setting | project on one host | checkout path, worktree base path, setup script | + +A host dropdown or host table inside project settings is likely enough for +host-specific project settings, similar to existing Windows/WSL-specific +settings patterns. + +## How Many Things Need To Change? + +The short answer is: **12 major change surfaces** need to fit the new model. + +Some are already partially implemented on this branch. The model is only +complete when all 12 speak the same project-first language. + +## The 12 Change Surfaces + +### 1. Shared Data Model + +`Repo` currently mixes durable project identity with host-local checkout facts. + +Needs: + +- first-class `Project` +- first-class `Host` +- first-class `ProjectHostSetup` +- explicit workspace ownership by `projectId`, `hostId`, and + `projectHostSetupId` +- repo-shaped compatibility projection while older APIs remain + +Status: partially implemented. + +### 2. Persistence And Migration + +Existing users need a boring migration. + +Needs: + +- derive one project per reliable durable identity +- derive one host setup per existing repo checkout +- avoid merging same-name folders unless provider/setup identity is reliable +- preserve old ids or aliases where compatibility requires it +- backfill existing workspaces with project/setup ownership when safe + +Status: partially implemented. + +### 3. Runtime And Request Ownership + +The UI can be project-first, but execution still happens on a host. + +Needs: + +- route terminals, agents, filesystem, browser, source control, hooks, and + automations through the workspace's owning host +- avoid using the currently focused host as a hidden global default for + workspace-owned operations +- scope cancellation and stale-response handling to the host/setup that owns the + request + +Status: partially implemented by multi-host groundwork; still needs an audit. + +### 4. Workspace Creation + +Creation must target a project and host, not just a repo id. + +Needs: + +- project picker +- run-on host picker +- unavailable-host reasons +- inline clone/import setup actions +- compatibility resolver from `{ projectId, hostId }` to current repo/setup + internals while old APIs remain + +Status: partially implemented. Ready setups and setup-target rows exist in the +composer; inline import exists; inline clone is wired for non-SSH local/runtime +hosts; SSH clone and provisioning remain. + +### 5. Project Setup Flow + +"Add repo" becomes a family of project/setup flows. + +Needs: + +- import existing folder on local host +- import existing folder over SSH +- clone project onto selected host +- set up an existing project on another host +- bulk setup when adding a new host +- future cloud provisioning hook + +Status: existing-folder setup is partially implemented; clone-on-host is wired +for local/runtime from the composer; SSH clone, provisioning, and bulk setup +remain. + +### 6. Sidebar Row Model + +The sidebar should be built from projects, hosts, setups, and workspaces. + +Needs: + +- project-first grouping +- host labels/subgroups only when useful +- host filters and online/offline status retained +- clear disconnected-host behavior +- drag/reorder rules for projects, host sections, and workspaces + +Status: partially implemented. Default all-host Projects view keeps projects +outermost; mixed-host cards show host context; disconnected-host polish remains. + +### 7. Project Settings + +Project settings need a global area plus host-specific setup sections. + +Needs: + +- project-global settings +- host-specific paths, worktree paths, setup scripts, and platform constraints +- host dropdown/table inside settings +- provider-neutral source-control settings + +Status: in progress. Available-hosts and setup-specific navigation exist; full +ownership split remains. + +### 8. Host Settings + +Host settings should describe the machine/runtime, not duplicate every project +setting. + +Needs: + +- connection details +- display name +- health/status +- server version and protocol compatibility +- platform/capabilities +- host-wide defaults and overrides + +Status: multi-host settings groundwork exists; needs alignment with +project-host setup settings. + +### 9. Version And Capability Compatibility + +New clients and old servers will coexist. + +Needs: + +- host capability probing +- fallback projection when project/setup APIs are missing +- disabled states with specific reasons +- structured errors for unsupported old-server actions +- old client / new server behavior that degrades safely + +Status: runtime compatibility exists; project/setup capability gating remains. + +### 10. Caches And Local State + +Some caches are project-global; many are host/setup-local. + +Needs: + +- classify caches as project, host, setup, or workspace scoped +- include host/setup ids in cache keys for refs, git status, filesystem state, + capabilities, terminals, browser sessions, and remote results +- prevent a response from one host from overwriting another host's state for the + same project + +Status: partially addressed for host partitioning; needs a project/setup audit. + +### 11. CLI And API + +External commands should speak project-first language. + +Needs: + +- `orca project list` +- `orca project setups` +- `orca project setup-existing-folder --project --host --path ` +- `orca worktree create --project --host ...` +- `orca worktree create --project-host-setup ...` +- compatibility aliases for old repo/worktree commands +- structured availability errors + +Status: partially implemented. + +### 12. Tests And Verification + +This crosses storage, routing, UI, SSH, and compatibility. + +Needs: + +- migration tests +- selector/sidebar grouping tests +- create-workspace tests +- setup-on-host tests +- settings ownership tests +- SSH end-to-end validation +- version mismatch tests +- Electron validation for sidebar, creation, and settings + +Status: model/API tests exist; full end-to-end UI, SSH, and version-skew +validation remain. + +## Implementation Shape + +Use an additive migration: + +1. Keep existing `Repo` APIs alive while adding project/setup records. +2. Use conservative identity matching so only reliably linked checkouts merge + into one project. +3. Teach creation flows to resolve `{ projectId, hostId }` into the current + backend through a ready setup. +4. Add setup-on-host flows for local, SSH, runtime, and future cloud hosts. +5. Split settings into client, host, project, and project-host setup ownership. +6. Audit host-local caches and runtime routing. +7. Move visible default UX to project-first once behavior is real. +8. Preserve a host-first operational view/filter for troubleshooting. +9. Validate migration, creation, settings, sidebar, SSH, and version skew before + calling the model complete. + +## Current Branch Status + +Already partially landed: + +- shared `Project` and `ProjectHostSetup` types +- compatibility projection from existing `Repo[]` +- persisted compatibility fields for projects and project-host setups +- renderer hydration and fallback for older runtimes +- project-aware sidebar grouping in existing repo grouping paths +- default all-host Projects sidebar keeps projects outermost +- host context badges for mixed-host project groups +- project-host-aware workspace creation target resolver +- optional workspace metadata for `projectId`, `hostId`, and + `projectHostSetupId` +- setup-existing-folder API plumbing through local IPC/preload/runtime paths +- project settings existing-folder setup form for known local, SSH, and active + runtime hosts +- CLI commands for listing projects/setups and creating worktrees by + project/setup +- composer `Run on` selector when a project has multiple ready setups +- setup-target `Run on` rows for known hosts where the selected project is not + set up yet +- inline import-existing-folder setup from the composer +- inline clone setup from the composer for not-yet-set-up non-SSH + local/runtime hosts + +Still needed: + +- finish SSH clone/provision flows +- durable cloned setup metadata while `Repo` remains the compatibility source +- bulk setup flows and setup for newly added hosts +- project settings split into global and host-specific ownership +- host settings/capability UI aligned with project setup +- complete cache/request ownership audit +- complete version-skew capability gating +- full Electron and SSH end-to-end validation + +## Final Product Position + +Keep hosts visible, but do not make them the durable outermost object. + +Projects should be the top-level user concept. Hosts, VMs, SSH targets, remote +servers, and future Orca cloud machines are places where a project can be set up +and where a workspace can run. diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index 2e143b1ed3f..12df69c887a 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -750,12 +750,18 @@ Landed so far: - 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 an inline composer clone action for not-yet-set-up non-SSH hosts. It + seeds a GitHub HTTPS clone URL when durable project identity is available, + lets the user paste a different URL, clones into the selected parent + directory through the existing local/runtime clone APIs, then links the + resulting checkout as the project's host setup. - 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 - routing. Sidebar row-builder tests now cover project-first multi-host grouping - and same-name repo separation without project identity. Workspace target tests - cover local-only fallback, focused-host setup selection, explicit + backfill, repo mutation synchronization, renderer hydration, runtime RPC + routing, local/runtime clone setup composition, and GitHub clone URL + inference. Sidebar row-builder tests now cover project-first multi-host + grouping and same-name repo separation without project identity. Workspace + target tests cover local-only fallback, focused-host setup selection, explicit project-plus-host resolution, same-name non-merging, and unavailable setup reasons. @@ -768,13 +774,18 @@ Important limitation: through their repo compatibility record. Settings now expose setup-specific host panes and existing-folder setup, but still use repo compatibility records underneath. +- SSH clone setup is not implemented yet. SSH hosts can import an existing + folder today; true SSH clone should either reuse a remote git-provider exec + path or add a remote clone flow with progress, abort, safe path derivation, + cleanup, and auth parity. +- The current clone setup action records the resulting checkout by calling the + existing-folder setup path, so durable `setupMethod: cloned` semantics are not + complete while the compatibility projection remains repo-backed. Remaining end-to-end work: -- 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 +- finish SSH clone, provisioning, and bulk setup-on-host flows - split settings into explicit client, host, project, and project-host setup scopes - validate the default project-first sidebar view in Electron and continue diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 6477ed4bcec..082ccb15e9d 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -74,6 +74,12 @@ type NewWorkspaceComposerCardProps = { path: string, kind: 'git' | 'folder' ) => Promise + projectHostCloneUrl: string | null + onProjectHostCloneSetup: ( + option: NeedsSetupProjectHostOption, + url: string, + destination: string + ) => Promise primaryActionLabel: string name: string onNameValueChange: (value: string) => void @@ -258,6 +264,8 @@ export default function NewWorkspaceComposerCard({ selectedProjectHostSetupId, onProjectHostSetupChange, onProjectHostExistingFolderSetup, + projectHostCloneUrl, + onProjectHostCloneSetup, primaryActionLabel, name, onNameValueChange, @@ -302,6 +310,9 @@ export default function NewWorkspaceComposerCard({ const [pendingSetupPath, setPendingSetupPath] = React.useState('') const [pendingSetupKind, setPendingSetupKind] = React.useState<'git' | 'folder'>('git') const [pendingSetupImporting, setPendingSetupImporting] = React.useState(false) + const [pendingCloneUrl, setPendingCloneUrl] = React.useState('') + const [pendingCloneDestination, setPendingCloneDestination] = React.useState('') + const [pendingCloneImporting, setPendingCloneImporting] = React.useState(false) const openModal = useAppStore((s) => s.openModal) const activeModal = useAppStore((s) => s.activeModal) const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) @@ -418,9 +429,13 @@ export default function NewWorkspaceComposerCard({ }, [onProjectHostSetupChange] ) - const handleNeedsSetupHostSelect = React.useCallback((option: NeedsSetupProjectHostOption) => { - setPendingSetupHostOptionId(option.id) - }, []) + const handleNeedsSetupHostSelect = React.useCallback( + (option: NeedsSetupProjectHostOption) => { + setPendingSetupHostOptionId(option.id) + setPendingCloneUrl(projectHostCloneUrl ?? '') + }, + [projectHostCloneUrl] + ) const handleImportExistingFolder = React.useCallback(async (): Promise => { if (!pendingSetupHostOption || !pendingSetupPath.trim()) { return @@ -441,6 +456,34 @@ export default function NewWorkspaceComposerCard({ setPendingSetupImporting(false) } }, [onProjectHostExistingFolderSetup, pendingSetupHostOption, pendingSetupKind, pendingSetupPath]) + const canClonePendingSetup = + !!pendingSetupHostOption && !pendingSetupHostOption.hostId.startsWith('ssh:') + const handleCloneProjectHost = React.useCallback(async (): Promise => { + if (!pendingSetupHostOption || !pendingCloneUrl.trim() || !pendingCloneDestination.trim()) { + return + } + setPendingCloneImporting(true) + try { + const imported = await onProjectHostCloneSetup( + pendingSetupHostOption, + pendingCloneUrl.trim(), + pendingCloneDestination.trim() + ) + if (imported) { + setPendingSetupHostOptionId(null) + setPendingCloneUrl(projectHostCloneUrl ?? '') + setPendingCloneDestination('') + } + } finally { + setPendingCloneImporting(false) + } + }, [ + onProjectHostCloneSetup, + pendingCloneDestination, + pendingCloneUrl, + pendingSetupHostOption, + projectHostCloneUrl + ]) useContextualTour( 'workspace-creation', eligibleRepos.length > 0 && Boolean(repoId), @@ -542,10 +585,67 @@ export default function NewWorkspaceComposerCard({
{translate( 'auto.components.NewWorkspaceComposerCard.setupHostExistingFolderTitle', - 'Import existing folder on {{value0}}', + 'Set up {{value0}}', { value0: pendingSetupHostOption.label } )}
+ {canClonePendingSetup ? ( +
+
+ {translate( + 'auto.components.NewWorkspaceComposerCard.cloneProjectOnHost', + 'Clone project' + )} +
+ setPendingCloneUrl(event.target.value)} + placeholder={translate( + 'auto.components.NewWorkspaceComposerCard.cloneUrlPlaceholder', + 'https://github.com/owner/repo.git' + )} + className="h-8 min-w-0 text-xs" + /> +
+ setPendingCloneDestination(event.target.value)} + placeholder={translate( + 'auto.components.NewWorkspaceComposerCard.cloneDestinationPlaceholder', + '/parent/directory/on/host' + )} + className="h-8 min-w-0 text-xs" + /> + +
+
+ ) : null} +
+ {translate( + 'auto.components.NewWorkspaceComposerCard.importExistingFolderOnHost', + 'Import existing folder' + )} +
Promise + projectHostCloneUrl: string | null + onProjectHostCloneSetup: ( + option: NeedsSetupProjectHostOption, + url: string, + destination: string + ) => Promise name: string onNameValueChange: (value: string) => void onSmartGitHubItemSelect: (item: GitHubWorkItem) => void @@ -311,6 +318,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS openSettingsPage: s.openSettingsPage, openSettingsTarget: s.openSettingsTarget, setupProjectExistingFolder: s.setupProjectExistingFolder, + setupProjectClone: s.setupProjectClone, prefetchWorktreeCreateBase: s.prefetchWorktreeCreateBase, prefetchWorkItems: s.prefetchWorkItems, fetchSparsePresets: s.fetchSparsePresets @@ -326,6 +334,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS openSettingsPage, openSettingsTarget, setupProjectExistingFolder, + setupProjectClone, prefetchWorktreeCreateBase, prefetchWorkItems, fetchSparsePresets @@ -390,6 +399,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) const selectedProjectId = selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectId : null + const selectedProject = selectedProjectId + ? (projects.find((project) => project.id === selectedProjectId) ?? null) + : null + const projectHostCloneUrl = getProjectHostCloneUrl(selectedProject) const selectedProjectHostSetupId = selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectHostSetupId @@ -1732,6 +1745,27 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }, [handleRepoChange, selectedRepo?.displayName, setupProjectExistingFolder] ) + const handleProjectHostCloneSetup = useCallback( + async ( + option: NeedsSetupProjectHostOption, + url: string, + destination: string + ): Promise => { + const result = await setupProjectClone({ + projectId: option.projectId, + hostId: option.hostId, + url, + destination, + displayName: selectedRepo?.displayName + }) + if (!result) { + return false + } + handleRepoChange(result.repo.id) + return true + }, + [handleRepoChange, selectedRepo?.displayName, setupProjectClone] + ) const showProjectRequiredError = useCallback((): void => { setProjectError('Choose or add a project before creating a workspace.') @@ -2602,6 +2636,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS selectedProjectHostSetupId, onProjectHostSetupChange: handleProjectHostSetupChange, onProjectHostExistingFolderSetup: handleProjectHostExistingFolderSetup, + projectHostCloneUrl, + onProjectHostCloneSetup: handleProjectHostCloneSetup, name, onNameValueChange: handleNameValueChange, onSmartGitHubItemSelect: handleSmartGitHubItemSelect, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a43968d2cfb..3f6d5ace068 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -994,7 +994,13 @@ "e5db1b0419": "Combined setup command", "runOn": "Run on", "addProjectBeforeWorkspace": "Add a project before creating a workspace.", - "setupHostExistingFolderTitle": "Import existing folder on {{value0}}", + "setupHostExistingFolderTitle": "Set up {{value0}}", + "cloneProjectOnHost": "Clone project", + "cloneUrlPlaceholder": "https://github.com/owner/repo.git", + "cloneDestinationPlaceholder": "/parent/directory/on/host", + "cloningHostSetup": "Cloning...", + "cloneHostSetup": "Clone", + "importExistingFolderOnHost": "Import existing folder", "setupHostExistingFolderPlaceholder": "/path/to/project/on/host", "setupKindGit": "Git repo", "setupKindFolder": "Folder", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 67febad226a..fb1457f742c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -994,7 +994,13 @@ "e5db1b0419": "組み合わせセットアップコマンド", "runOn": "実行先", "addProjectBeforeWorkspace": "ワークスペースを作成する前にプロジェクトを追加します。", - "setupHostExistingFolderTitle": "Import existing folder on {{value0}}", + "setupHostExistingFolderTitle": "Set up {{value0}}", + "cloneProjectOnHost": "Clone project", + "cloneUrlPlaceholder": "https://github.com/owner/repo.git", + "cloneDestinationPlaceholder": "/parent/directory/on/host", + "cloningHostSetup": "Cloning...", + "cloneHostSetup": "Clone", + "importExistingFolderOnHost": "Import existing folder", "setupHostExistingFolderPlaceholder": "/path/to/project/on/host", "setupKindGit": "Git repo", "setupKindFolder": "Folder", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3b9b0dc439d..8eaf5667c63 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -994,7 +994,13 @@ "e5db1b0419": "결합된 설정 명령", "runOn": "실행 위치", "addProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 추가하세요.", - "setupHostExistingFolderTitle": "Import existing folder on {{value0}}", + "setupHostExistingFolderTitle": "Set up {{value0}}", + "cloneProjectOnHost": "Clone project", + "cloneUrlPlaceholder": "https://github.com/owner/repo.git", + "cloneDestinationPlaceholder": "/parent/directory/on/host", + "cloningHostSetup": "Cloning...", + "cloneHostSetup": "Clone", + "importExistingFolderOnHost": "Import existing folder", "setupHostExistingFolderPlaceholder": "/path/to/project/on/host", "setupKindGit": "Git repo", "setupKindFolder": "Folder", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 14198373adf..070183698ec 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -994,7 +994,13 @@ "e5db1b0419": "组合设置命令", "runOn": "运行位置", "addProjectBeforeWorkspace": "在创建工作区之前添加项目。", - "setupHostExistingFolderTitle": "Import existing folder on {{value0}}", + "setupHostExistingFolderTitle": "Set up {{value0}}", + "cloneProjectOnHost": "Clone project", + "cloneUrlPlaceholder": "https://github.com/owner/repo.git", + "cloneDestinationPlaceholder": "/parent/directory/on/host", + "cloningHostSetup": "Cloning...", + "cloneHostSetup": "Clone", + "importExistingFolderOnHost": "Import existing folder", "setupHostExistingFolderPlaceholder": "/path/to/project/on/host", "setupKindGit": "Git repo", "setupKindFolder": "Folder", diff --git a/src/renderer/src/lib/project-host-clone-url.test.ts b/src/renderer/src/lib/project-host-clone-url.test.ts new file mode 100644 index 00000000000..26a0c9f1395 --- /dev/null +++ b/src/renderer/src/lib/project-host-clone-url.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { Project } from '../../../shared/types' +import { getProjectHostCloneUrl } from './project-host-clone-url' + +function createProject(overrides: Partial = {}): Project { + return { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('getProjectHostCloneUrl', () => { + it('builds a GitHub HTTPS clone URL from provider identity', () => { + expect( + getProjectHostCloneUrl( + createProject({ + providerIdentity: { + provider: 'github', + owner: ' stablyai ', + repo: ' orca ' + } + }) + ) + ).toBe('https://github.com/stablyai/orca.git') + }) + + it('returns null when provider identity is missing or incomplete', () => { + expect(getProjectHostCloneUrl(createProject())).toBeNull() + expect( + getProjectHostCloneUrl( + createProject({ + providerIdentity: { + provider: 'github', + owner: '', + repo: 'orca' + } + }) + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/project-host-clone-url.ts b/src/renderer/src/lib/project-host-clone-url.ts new file mode 100644 index 00000000000..ae1937ca1a3 --- /dev/null +++ b/src/renderer/src/lib/project-host-clone-url.ts @@ -0,0 +1,14 @@ +import type { Project } from '../../../shared/types' + +export function getProjectHostCloneUrl(project: Project | null | undefined): string | null { + const identity = project?.providerIdentity + if (!identity || identity.provider !== 'github') { + return null + } + const owner = identity.owner.trim() + const repo = identity.repo.trim() + if (!owner || !repo) { + return null + } + return `https://github.com/${owner}/${repo}.git` +} diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 9f5e9c0e82f..2394ffc1aeb 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -36,6 +36,7 @@ const sshRepo: Repo = { const reposList = vi.fn() const reposAdd = vi.fn() const reposPickFolder = vi.fn() +const reposClone = vi.fn() const reposRemove = vi.fn() const reposUpdate = vi.fn() const reposReorder = vi.fn() @@ -50,6 +51,7 @@ beforeEach(() => { reposList.mockReset() reposAdd.mockReset() reposPickFolder.mockReset() + reposClone.mockReset() reposRemove.mockReset() reposUpdate.mockReset() reposReorder.mockReset() @@ -66,6 +68,7 @@ beforeEach(() => { repos: { list: reposList, add: reposAdd, + clone: reposClone, pickFolder: reposPickFolder, remove: reposRemove, update: reposUpdate, @@ -410,6 +413,133 @@ describe('repo slice runtime routing', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('clones a project locally before aligning it as a host setup', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + const clonedRepo = { ...localRepo, path: '/workspace/project' } + const setup: ProjectHostSetup = { + id: clonedRepo.id, + projectId: project.id, + hostId: 'local', + repoId: clonedRepo.id, + path: clonedRepo.path, + displayName: clonedRepo.displayName, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + reposClone.mockResolvedValue(clonedRepo) + projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: clonedRepo }) + const store = createTestStore() + + await expect( + store.getState().setupProjectClone({ + projectId: project.id, + hostId: 'local', + url: 'https://github.com/stablyai/orca.git', + destination: '/workspace', + displayName: 'Project' + }) + ).resolves.toEqual({ + project, + setup, + repo: { ...clonedRepo, executionHostId: 'local' } + }) + + expect(reposClone).toHaveBeenCalledWith({ + url: 'https://github.com/stablyai/orca.git', + destination: '/workspace' + }) + expect(projectsSetupExistingFolder).toHaveBeenCalledWith({ + projectId: project.id, + hostId: 'local', + path: clonedRepo.path, + kind: 'git', + displayName: 'Project' + }) + }) + + it('clones a project on a runtime host before aligning it as a host setup', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['remote-repo'], + createdAt: 1, + updatedAt: 1 + } + const clonedRepo = { ...remoteRepo, path: '/srv/project' } + const setup: ProjectHostSetup = { + id: clonedRepo.id, + projectId: project.id, + hostId: 'local', + repoId: clonedRepo.id, + path: clonedRepo.path, + displayName: clonedRepo.displayName, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'rpc-clone', + ok: true, + result: { repo: clonedRepo }, + _meta: { runtimeId: 'runtime-remote' } + }) + .mockResolvedValueOnce({ + id: 'rpc-setup', + ok: true, + result: { result: { project, setup, repo: clonedRepo } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + + await expect( + store.getState().setupProjectClone({ + projectId: project.id, + hostId: 'runtime:env-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv', + displayName: 'Project' + }) + ).resolves.toEqual({ + project, + setup: { ...setup, hostId: 'runtime:env-1', executionHostId: 'runtime:env-1' }, + repo: { ...clonedRepo, executionHostId: 'runtime:env-1' } + }) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'repo.clone', + params: { + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }, + timeoutMs: 10 * 60_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'projectHostSetup.setupExistingFolder', + params: { + projectId: project.id, + hostId: 'runtime:env-1', + path: clonedRepo.path, + kind: 'git', + displayName: 'Project' + }, + timeoutMs: 15_000 + }) + }) + it('keeps runtime ownership when a runtime repo is moved between groups', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-move', diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index c041faf032d..3454e786583 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -12,6 +12,7 @@ import type { ProjectHostSetup, ProjectGroupImportResult, NestedRepoScanResult, + ProjectHostSetupCloneArgs, ProjectHostSetupExistingFolderArgs, ProjectHostSetupResult } from '../../../../shared/types' @@ -278,6 +279,7 @@ export type RepoSlice = { setupProjectExistingFolder: ( args: ProjectHostSetupExistingFolderArgs ) => Promise + setupProjectClone: (args: ProjectHostSetupCloneArgs) => Promise addNonGitFolder: (path: string) => Promise scanNestedRepos: ( path: string, @@ -714,6 +716,50 @@ export const createRepoSlice: StateCreator = (set, } }, + setupProjectClone: async (args) => { + try { + const parsedHost = parseExecutionHostId(args.hostId) + if (parsedHost?.kind === 'ssh') { + throw new Error( + 'Clone setup is not available for SSH hosts yet. Import an existing folder on that host instead.' + ) + } + const target = getProjectSetupRuntimeTarget(args.hostId) + const repo = + target.kind === 'local' + ? await window.api.repos.clone({ + url: args.url, + destination: args.destination + }) + : ( + await callRuntimeRpc<{ repo: Repo }>( + target, + 'repo.clone', + { + url: args.url, + destination: args.destination + }, + { timeoutMs: 10 * 60_000 } + ) + ).repo + return await get().setupProjectExistingFolder({ + projectId: args.projectId, + hostId: args.hostId, + path: repo.path, + kind: 'git', + displayName: args.displayName + }) + } catch (err) { + console.error('Failed to clone project on host:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + addRepo: async () => { const target = getActiveRuntimeTarget(get().settings) if (target.kind !== 'local') { diff --git a/src/shared/types.ts b/src/shared/types.ts index 2ae06b03fec..7d0cb1e0c2d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -133,6 +133,14 @@ export type ProjectHostSetupExistingFolderArgs = { displayName?: string } +export type ProjectHostSetupCloneArgs = { + projectId: string + hostId: ExecutionHostId + url: string + destination: string + displayName?: string +} + export type ProjectHostSetupResult = { project: Project setup: ProjectHostSetup