mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 00:03:09 +00:00
Clone project host setup from composer
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <id> --host <id> --path <path>`
|
||||
- `orca worktree create --project <id> --host <id> ...`
|
||||
- `orca worktree create --project-host-setup <id> ...`
|
||||
- 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.
|
||||
@@ -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
|
||||
|
||||
@@ -74,6 +74,12 @@ type NewWorkspaceComposerCardProps = {
|
||||
path: string,
|
||||
kind: 'git' | 'folder'
|
||||
) => Promise<boolean>
|
||||
projectHostCloneUrl: string | null
|
||||
onProjectHostCloneSetup: (
|
||||
option: NeedsSetupProjectHostOption,
|
||||
url: string,
|
||||
destination: string
|
||||
) => Promise<boolean>
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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({
|
||||
<div className="text-xs font-medium text-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.setupHostExistingFolderTitle',
|
||||
'Import existing folder on {{value0}}',
|
||||
'Set up {{value0}}',
|
||||
{ value0: pendingSetupHostOption.label }
|
||||
)}
|
||||
</div>
|
||||
{canClonePendingSetup ? (
|
||||
<div className="space-y-2 rounded-sm border border-border/70 bg-background/50 p-2">
|
||||
<div className="text-[11px] font-medium text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.cloneProjectOnHost',
|
||||
'Clone project'
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={pendingCloneUrl}
|
||||
onChange={(event) => setPendingCloneUrl(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.NewWorkspaceComposerCard.cloneUrlPlaceholder',
|
||||
'https://github.com/owner/repo.git'
|
||||
)}
|
||||
className="h-8 min-w-0 text-xs"
|
||||
/>
|
||||
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Input
|
||||
value={pendingCloneDestination}
|
||||
onChange={(event) => setPendingCloneDestination(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.NewWorkspaceComposerCard.cloneDestinationPlaceholder',
|
||||
'/parent/directory/on/host'
|
||||
)}
|
||||
className="h-8 min-w-0 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
disabled={
|
||||
!pendingCloneUrl.trim() ||
|
||||
!pendingCloneDestination.trim() ||
|
||||
pendingCloneImporting
|
||||
}
|
||||
onClick={() => void handleCloneProjectHost()}
|
||||
className="shrink-0"
|
||||
>
|
||||
{pendingCloneImporting
|
||||
? translate(
|
||||
'auto.components.NewWorkspaceComposerCard.cloningHostSetup',
|
||||
'Cloning...'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.NewWorkspaceComposerCard.cloneHostSetup',
|
||||
'Clone'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-[11px] font-medium text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.importExistingFolderOnHost',
|
||||
'Import existing folder'
|
||||
)}
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_7rem]">
|
||||
<Input
|
||||
value={pendingSetupPath}
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
type NeedsSetupProjectHostOption,
|
||||
type ProjectHostSetupOption
|
||||
} from '@/lib/project-host-setup-options'
|
||||
import { getProjectHostCloneUrl } from '@/lib/project-host-clone-url'
|
||||
import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry'
|
||||
import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides'
|
||||
import { queueNewWorkspaceTerminalFocus } from '@/lib/new-workspace-terminal-focus'
|
||||
@@ -165,6 +166,12 @@ export type ComposerCardProps = {
|
||||
path: string,
|
||||
kind: 'git' | 'folder'
|
||||
) => Promise<boolean>
|
||||
projectHostCloneUrl: string | null
|
||||
onProjectHostCloneSetup: (
|
||||
option: NeedsSetupProjectHostOption,
|
||||
url: string,
|
||||
destination: string
|
||||
) => Promise<boolean>
|
||||
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<boolean> => {
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> = {}): 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()
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<ProjectHostSetupResult | null>
|
||||
setupProjectClone: (args: ProjectHostSetupCloneArgs) => Promise<ProjectHostSetupResult | null>
|
||||
addNonGitFolder: (path: string) => Promise<Repo | null>
|
||||
scanNestedRepos: (
|
||||
path: string,
|
||||
@@ -714,6 +716,50 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user