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: c729811995ffcaa0
raw_hash: 9fd83c4d176cf4c6
vouched: 2026-10-22T20:47:29Z
body_hash: bcbd8e0b666b1d3c
raw_hash: 33b2e33890f7fef6
vouched: 2026-10-22T20:54:01Z
lines: 235-824
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-22T20:29:16Z
norm: '1'
sig: 063d0f00fea02511
body_hash: c729811995ffcaa0
raw_hash: 9fd83c4d176cf4c6
vouched: 2026-10-22T20:47:29Z
body_hash: bcbd8e0b666b1d3c
raw_hash: 33b2e33890f7fef6
vouched: 2026-10-22T20:54:01Z
lines: 330-1237
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Dashboard
created: 2026-10-19T20:51:03Z
norm: '1'
sig: 4bfb45497157c0e0
body_hash: 97a54ddcbd872f30
raw_hash: 758dbb3d2b2fb407
vouched: 2026-10-22T15:14:58Z
body_hash: 7bc89e0d12e7b984
raw_hash: cc8a4b9d6c396b11
vouched: 2026-10-22T20:44:27Z
lines: 256-606
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5JNQ72H3WS44JG82D60Q37M
anchor: function InitGit
created: 2026-10-22T20:44:24Z
norm: '1'
sig: e05f5c81094473f3
body_hash: faffdcfc736d2780
raw_hash: e2bf3f4586e0df5c
lines: 52-111
---

a folder without a commit is re-read only by a listing that opening triggers; the card re-opens its folder on mount and focus so outside commits show
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ id: 01M5AYXJQNG05XSJ4MD06CY2TF
anchor: function SetupProject
created: 2026-10-19T20:51:15Z
norm: '1'
sig: 5fe4de63348584e9
body_hash: 7f0b00583c29c9fd
raw_hash: 0faa3040f0175372
sig: c2572cc83b5ddf2c
body_hash: f7b40f021d23c63c
raw_hash: 617255c5382e68e6
vouched: 2026-10-22T20:44:27Z
lines: 24-163
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ id: 01M5J0F003CDDGXJ61TYSB9NQ1
anchor: function open
created: 2026-10-22T14:32:55Z
norm: '1'
sig: 69741dfdfc216b17
body_hash: 70f23e213716ef8e
raw_hash: ee7a4de150371af3
sig: 2a6b090bac83313c
body_hash: 9b3ecf598f795095
raw_hash: b861580496c0d206
vouched: 2026-10-22T20:44:27Z
lines: 131-142
---

Expand Down
22 changes: 20 additions & 2 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -604,8 +604,24 @@ export function App() {
}

async function open(path: string) {
const value = await host.request<Project>({ op: "project-open", path });
chooseProject(projectId(hello.host, value.path, value.repo));
const value = await host.request<Project & { from?: string }>({ op: "project-open", path });
const id = projectId(hello.host, value.path, value.repo);
// A GitHub project is one project on this host however many checkouts of it are opened.
const other = value.repo
&& opened?.find((checkout) =>
checkout.path !== value.path && projectId(hello.host, checkout.path, checkout.repo) === id
);
chooseProject(id);
if (other) {
toast(`${value.name} is already a project at ${other.path}`, {
description: "Channels in it keep starting from that checkout.",
});
} else if (value.from) {
toast(`Opened ${value.name}`, {
description:
`${value.from} is inside the Git repository at ${value.path}, so Ace opened that.`,
});
}
}

// Declared, not a const: the All Projects groups above call it during render.
Expand Down Expand Up @@ -1096,6 +1112,7 @@ export function App() {
name={current.label}
ignored={current.ignored}
disabled={!connected}
path={current.path}
onInit={() => init(current.path)}
/>
</div>
Expand Down Expand Up @@ -1142,6 +1159,7 @@ export function App() {
{setup && (
<SetupProject
project={setup.project}
opened={opened || []}
starting={!!setup.start}
onSetup={finishSetup}
onClose={() => setSetup(undefined)}
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ export function Dashboard(
{needsGit && (
<div className="mx-auto mt-9 max-w-[39rem]">
<InitGit
path={project.path}
name={project.label}
ignored={project.ignored}
disabled={!connected}
Expand Down
24 changes: 20 additions & 4 deletions apps/app/src/open-project.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useId, useState } from "react";
import { useEffect, useId, useState } from "react";

import {
Button,
Expand All @@ -13,6 +13,7 @@ import {
import { IconArrowDownToLine, IconBranch, IconFolderSearch } from "@ace/ui/icons";

import { desktop } from "./desktop";
import { host } from "./host";

export function EmptyProjects({ onOpen, onClone, disabled }: {
onOpen: () => void;
Expand Down Expand Up @@ -48,14 +49,24 @@ export function EmptyProjects({ onOpen, onClone, disabled }: {
}

/** Lanes are Git worktrees, so a folder needs a commit before it can start channels. */
export function InitGit({ name, ignored, disabled, onInit }: {
export function InitGit({ path, name, ignored, disabled, onInit }: {
path: string;
name: string;
/** Ace won't commit under a folder's own `.gitignore`, so the person commits it themselves. */
ignored?: boolean;
disabled: boolean;
onInit: () => Promise<void>;
}) {
const [busy, setBusy] = useState(false);
// A commit made outside Ace, say in a terminal, shows once the host reads the folder again, as
// opening it does; listings alone don't.
useEffect(() => {
if (disabled) return;
const check = () => void host.request({ op: "project-open", path }).catch(() => {});
check();
addEventListener("focus", check);
return () => removeEventListener("focus", check);
}, [path, disabled]);

async function init() {
setBusy(true);
Expand Down Expand Up @@ -148,9 +159,14 @@ export function OpenProject({ onOpen, onClose }: {
placeholder="~/code/project"
value={path}
disabled={busy}
onChange={(event) => setPath(event.target.value)}
onChange={(event) => {
setPath(event.target.value);
setError("");
}}
/>
{error && <p role="alert" className="mt-2 text-xs text-destructive">{error}</p>}
{error && (
<p role="alert" className="mt-2 text-xs text-destructive wrap-anywhere">{error}</p>
)}
</form>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={onClose}>Cancel</Button>
Expand Down
37 changes: 29 additions & 8 deletions apps/app/src/setup-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
DialogTitle,
Input,
} from "@ace/ui";
import { githubRepo } from "@ace/host/protocol";
import { githubRepo, NOT_GITHUB, type Project } from "@ace/host/protocol";

import { desktop, here } from "./desktop";
import { useFolder } from "./folder";
Expand All @@ -21,8 +21,10 @@ import type { AppProject } from "./projects";
* project, or opens the matching checkout already at the chosen folder. Nothing is cloned until
* the person confirms the destination.
*/
export function SetupProject({ project, starting, onSetup, onClose }: {
export function SetupProject({ project, opened, starting, onSetup, onClose }: {
project?: AppProject & { repo: string };
/** This host's projects, to say when a cloned repository already is one. */
opened: Project[];
starting: boolean;
onSetup: (repo: string, path: string) => Promise<void>;
onClose: () => void;
Expand All @@ -37,7 +39,10 @@ export function SetupProject({ project, starting, onSetup, onClose }: {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const repo = project?.repo || url.trim();
const name = githubRepo(repo)?.split("/")[1];
const full = githubRepo(repo);
const name = full?.split("/")[1];
const existing = !project && full
&& opened.find((value) => value.repo?.toLowerCase() === full.toLowerCase());
// The host expands `~`, so the app never assumes where this machine's home folder is.
const base = (parent ?? folder)?.replace(/\/$/, "");
const path = edited ?? (base && name ? `${base}/${name}` : "");
Expand All @@ -62,6 +67,7 @@ export function SetupProject({ project, starting, onSetup, onClose }: {
if (!chosen) return;
setParent(chosen);
setEdited(undefined);
setError("");
} catch (error) {
setError((error as Error).message);
}
Expand Down Expand Up @@ -94,7 +100,7 @@ export function SetupProject({ project, starting, onSetup, onClose }: {
event.preventDefault();
void setup();
}}
className="flex flex-col gap-3"
className="flex min-w-0 flex-col gap-3"
>
{!project && (
<div>
Expand All @@ -108,8 +114,20 @@ export function SetupProject({ project, starting, onSetup, onClose }: {
disabled={busy}
spellCheck={false}
placeholder="https://github.com/owner/name"
onChange={(event) => setUrl(event.target.value)}
onChange={(event) => {
setUrl(event.target.value);
setError("");
}}
/>
{url.trim() && !full && (
<p className="mt-1.5 text-xs text-muted-foreground">{NOT_GITHUB}</p>
)}
{existing && (
<p className="mt-1.5 text-xs text-muted-foreground wrap-anywhere">
{full} is already a project at{" "}
{existing.path}. Another copy won't add a second project.
</p>
)}
</div>
)}
<div>
Expand All @@ -123,7 +141,10 @@ export function SetupProject({ project, starting, onSetup, onClose }: {
disabled={busy}
spellCheck={false}
placeholder={base && `${base}/name`}
onChange={(event) => setEdited(event.target.value)}
onChange={(event) => {
setEdited(event.target.value);
setError("");
}}
/>
{desktop && (
<Button
Expand All @@ -138,12 +159,12 @@ export function SetupProject({ project, starting, onSetup, onClose }: {
</div>
</div>
{error && (
<p role="alert" className="whitespace-pre-wrap text-xs text-destructive">
<p role="alert" className="whitespace-pre-wrap text-xs text-destructive wrap-anywhere">
{error}
</p>
)}
</form>
<DialogFooter>
<DialogFooter className="min-w-0">
<Button variant="outline" disabled={busy} onClick={onClose}>Cancel</Button>
<Button
type="submit"
Expand Down
8 changes: 2 additions & 6 deletions apps/host/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
} from "@ace/channel/protocol";

import { githubEnv } from "./keys";
import { githubRepo, type HostRequest } from "./protocol";
import { githubRepo, type HostRequest, NOT_GITHUB } from "./protocol";

const REPO = /^[a-z\d][a-z\d-]*\/[a-z\d_.-]+$/i;
const FIELDS = "number,title,url,state,author,createdAt,updatedAt,labels";
Expand Down Expand Up @@ -123,11 +123,7 @@ export async function found(repo: string): Promise<boolean> {
/** A GitHub repository URL, SSH remote, or owner/name, as a valid owner/name. */
export function parse(value: string): string {
const repo = githubRepo(value);
if (!repo) {
throw new Error(
"Ace clones GitHub repositories. Enter one such as https://github.com/owner/name or owner/name.",
);
}
if (!repo) throw new Error(NOT_GITHUB);
return repository(repo);
}

Expand Down
15 changes: 9 additions & 6 deletions apps/host/src/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,18 +127,21 @@ export async function list(): Promise<Project[]> {
const expand = (value: string) =>
value === "~" ? homedir() : value.startsWith("~/") ? homedir() + value.slice(1) : value;

/** Opening a folder must not require model credentials or create a channel. */
export async function open(value: string): Promise<Project> {
/**
* Opening a folder must not require model credentials or create a channel. A folder inside a
* repository opens that repository, and `from` names the folder asked for so the app can say so.
*/
export async function open(value: string): Promise<Project & { from?: string }> {
// A relative path would resolve against Ace Helper's working directory, which no one sees.
const expanded = expand(value.trim());
if (!isAbsolute(expanded)) throw new Error("Enter a full folder path, such as ~/code/project");
if (!existsSync(expanded)) throw new Error(`There's no folder at ${expanded}. Check the path.`);
let path = realpathSync(expanded);
if (!statSync(path).isDirectory()) throw new Error("Choose a folder to open as a project");
path = realpathSync((await checkout(path)).root);
const folder = realpathSync(expanded);
if (!statSync(folder).isDirectory()) throw new Error("Choose a folder to open as a project");
const path = realpathSync((await checkout(folder)).root);
const opened = paths();
if (!opened.includes(path)) save([...opened, path]);
return project(path);
return { ...await project(path), ...(path === folder ? {} : { from: folder }) };
}

/** Removing a project forgets the folder; its files and lanes stay where they are. */
Expand Down
4 changes: 4 additions & 0 deletions apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ export const projectId = (host: string, path: string, repo?: string) =>
export const channelProject = (channel: Pick<Listing, "host" | "project" | "root" | "repo">) =>
projectId(channel.host, root(channel), channel.repo);

/** What the host and the app say about a repository `githubRepo` doesn't recognize. */
export const NOT_GITHUB =
"Ace clones GitHub repositories. Enter one such as https://github.com/owner/name or owner/name.";

/**
* The owner/name a GitHub URL, SSH remote, or owner/name names, or undefined for anything else.
* The app reads it to suggest a clone's folder and the host to clone, so both agree on the name.
Expand Down