Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-15T03:38:04Z
norm: '1'
sig: 063d0f00fea02511
body_hash: 3ae5f8490cc90da0
raw_hash: 7e04439bd64b4ba8
vouched: 2026-10-19T20:51:12Z
body_hash: 7ffeaa95733dc612
raw_hash: a73f79d41002c2a0
vouched: 2026-10-19T20:54:33Z
lines: 235-824
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5AYXJQNG05XSJ4MD06CY2TF
anchor: function SetupProject
created: 2026-10-19T20:51:15Z
norm: '1'
sig: 5fe4de63348584e9
body_hash: 7f0b00583c29c9fd
raw_hash: 0faa3040f0175372
lines: 24-163
---

a picked folder is the clone's parent; the clone's own folder still follows the repository name
43 changes: 26 additions & 17 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -514,9 +514,14 @@ export function App() {
/** Channels only start on this host; setup may clone here first, never on the other host. */
async function finishSetup(repo: string, path: string) {
if (!setup) return;
const value = await host.request<Project>({ op: "project-clone", repo, path });
const value = await host.request<Project & { found?: true }>({
op: "project-clone",
repo,
path,
});
const id = projectId(hello.host, value.path, value.repo);
setSetup(undefined);
if (value.found) toast.success(`Opened the checkout already at ${value.path}`);
if (!setup.start) return chooseProject(id);
const target: AppProject = {
...value,
Expand Down Expand Up @@ -901,10 +906,9 @@ export function App() {
)}
{!allProjects && !local && (
<p className="max-w-[34rem] text-sm text-muted-foreground">
{current.label} is checked out on {[...current.hosts].join(", ")}.{" "}
{current.repo
? `Set it up on ${here} to start channels here.`
: "Open a checkout on this host to start channels here."}
{current.label} isn't on {here} yet. {current.repo
? "Set it up to start channels here."
: `Open a checkout of it on ${here} to start channels here.`}
</p>
)}
{!allProjects && needsGit && (
Expand All @@ -918,18 +922,23 @@ export function App() {
)}
{!allProjects && creatable && (
<div className="flex gap-2">
{!local && (
<Button
variant="outline"
disabled={!connected}
onClick={() => startSetup(current)}
>
Set up on {here}
</Button>
)}
<Button disabled={!connected} onClick={() => void create()}>
<IconPlus aria-hidden />New channel
</Button>
{local
? (
<Button
disabled={!connected}
onClick={() => void create()}
>
<IconPlus aria-hidden />New channel
</Button>
)
: (
<Button
disabled={!connected}
onClick={() => startSetup(current)}
>
Set up on {here}
</Button>
)}
</div>
)}
</div>
Expand Down
72 changes: 51 additions & 21 deletions apps/app/src/setup-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import {
} from "@ace/ui";
import { githubRepo } from "@ace/host/protocol";

import { here } from "./desktop";
import { desktop, here } from "./desktop";
import { useFolder } from "./folder";
import type { AppProject } from "./projects";

/**
* Clones a project known from other hosts, or a GitHub repository by URL when there is no
* Clones a project that isn't on this machine yet, or a GitHub repository by URL when there is no
* project, or opens the matching checkout already at the chosen folder. Nothing is cloned until
* the person confirms the destination.
*/
Expand All @@ -30,14 +30,16 @@ export function SetupProject({ project, starting, onSetup, onClose }: {
const id = useId();
const { folder } = useFolder();
const [url, setUrl] = useState("");
// A picked folder holds the clone; the clone's own folder still follows the repository's name.
const [parent, setParent] = useState<string>();
// The folder follows the repository's name until the person edits it.
const [edited, setEdited] = useState<string>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const repo = project?.repo || url.trim();
const name = githubRepo(repo)?.split("/")[1];
// The host expands `~`, so the app never assumes where this machine's home folder is.
const base = folder?.replace(/\/$/, "");
const base = (parent ?? folder)?.replace(/\/$/, "");
const path = edited ?? (base && name ? `${base}/${name}` : "");
const ready = !!repo && !!path.trim();

Expand All @@ -53,6 +55,18 @@ export function SetupProject({ project, starting, onSetup, onClose }: {
}
}

async function choose() {
if (!desktop) return;
try {
const chosen = await desktop.project();
if (!chosen) return;
setParent(chosen);
setEdited(undefined);
} catch (error) {
setError((error as Error).message);
}
}

return (
<Dialog
open
Expand All @@ -68,13 +82,10 @@ export function SetupProject({ project, starting, onSetup, onClose }: {
</DialogTitle>
<DialogDescription>
{project
? `${project.label} is checked out on ${
[...project.hosts].join(", ")
}. Clone ${project.repo} into a folder on ${here}${
starting ? " to start the channel here." : "."
} `
: `Clone a GitHub repository into a folder on ${here} and add it as a project. `}
A checkout of it already in that folder is opened instead.
? `${project.label} isn't on ${here} yet. Clone ${project.repo} from GitHub to get a copy here${
starting ? ", then start the channel." : "."
}`
: `Clone a GitHub repository to ${here} and add it as a project.`}
</DialogDescription>
</DialogHeader>
<form
Expand Down Expand Up @@ -105,15 +116,26 @@ export function SetupProject({ project, starting, onSetup, onClose }: {
<label htmlFor={`${id}-path`} className="mb-1.5 block text-xs font-medium">
Folder
</label>
<Input
id={`${id}-path`}
autoFocus={!!project}
value={path}
disabled={busy}
spellCheck={false}
placeholder={base && `${base}/name`}
onChange={(event) => setEdited(event.target.value)}
/>
<div className="flex gap-2">
<Input
id={`${id}-path`}
value={path}
disabled={busy}
spellCheck={false}
placeholder={base && `${base}/name`}
onChange={(event) => setEdited(event.target.value)}
/>
{desktop && (
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => void choose()}
>
Choose another folder…
</Button>
)}
</div>
</div>
{error && (
<p role="alert" className="whitespace-pre-wrap text-xs text-destructive">
Expand All @@ -123,8 +145,16 @@ export function SetupProject({ project, starting, onSetup, onClose }: {
</form>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={onClose}>Cancel</Button>
<Button type="submit" form={id} disabled={busy || !ready}>
{busy ? "Setting up…" : starting ? "Clone and start channel" : "Clone and open"}
<Button
type="submit"
form={id}
autoFocus={!!project}
disabled={busy || !ready}
className="min-w-0 shrink"
>
<span className="truncate">
{busy ? "Setting up…" : ready ? `Clone to ${path.trim()}` : "Clone"}
</span>
</Button>
</DialogFooter>
</DialogContent>
Expand Down
9 changes: 6 additions & 3 deletions apps/host/src/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,12 @@ const cloning = new Set<string>();

/**
* Sets up a GitHub repository on this host: clones it into `value`, or opens the checkout of it
* already there. Anything else at `value` is left untouched.
* already there, marked `found`. Anything else at `value` is left untouched.
*/
export async function setup(source: string, value: string): Promise<Project> {
export async function setup(
source: string,
value: string,
): Promise<Project & { found?: true }> {
const repo = parse(source);
const expanded = expand(value.trim());
if (!isAbsolute(expanded)) throw new Error("Enter a full folder path, such as ~/code/project");
Expand All @@ -238,7 +241,7 @@ export async function setup(source: string, value: string): Promise<Project> {
if (found?.toLowerCase() === repo.toLowerCase()) {
// An interrupted clone already names its remote but has no commit to work from.
const head = await git(real, ["rev-parse", "--verify", "-q", "HEAD"]);
if (head.status === 0) return await open(real);
if (head.status === 0) return { ...(await open(real)), found: true };
throw new Error(
`${path} has an incomplete clone of ${repo}. Remove it or choose another folder.`,
);
Expand Down