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
95 changes: 77 additions & 18 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,21 @@ import { Channel, ChannelDetails, type ChannelDraft } from "./channel";
import { Dashboard } from "./dashboard";
import { Github, GithubSidebar } from "./github";
import { useGithubRefresh } from "./github-cache";
import { desktop, titlebar } from "./desktop";
import { desktop, here, titlebar } from "./desktop";
import { host } from "./host";
import { Rename } from "./layout/rename";
import { Navigation, type Page, WindowControls } from "./navigation";
import { EmptyProjects, OpenProject } from "./open-project";
import { type AppProject, channelProject, label, projectId, projects } from "./projects";
import {
type AppProject,
channelProject,
checkoutId,
label,
projectId,
projects,
} from "./projects";
import { Settings } from "./settings";
import { SetupProject } from "./setup-project";
import { UpdateNotice } from "./updates";

// Older versions saved an inline GitHub item per project; Ace now opens items on GitHub.
Expand Down Expand Up @@ -201,6 +209,11 @@ export function App() {
{},
);
const [adding, setAdding] = useState(false);
/** A project to clone here; `start` continues into channel creation with its initial text. */
const [setup, setSetup] = useState<{
project: AppProject & { repo: string };
start?: { text?: string; onCreated?: () => void };
}>();
const [settings, setSettings] = useState<boolean | "updates">(false);
const [draft, setDraft] = useState<ChannelDraft>();
const [renaming, setRenaming] = useState<{ id: string; name: string }>();
Expand Down Expand Up @@ -268,6 +281,8 @@ export function App() {
);
const connected = status === "open" && !!hello.host;
const local = current?.host === hello.host;
// GitHub projects can be cloned here; other folders exist only on the hosts that hold them.
const creatable = local || !!current?.repo;
const repos: SessionSidebarRepo[] = [
{
id: ALL_PROJECTS,
Expand Down Expand Up @@ -304,7 +319,7 @@ export function App() {
rows,
sort: "none",
collapsed: collapsed[id],
onNewSession: connected && value.host === hello.host
onNewSession: connected && (value.host === hello.host || value.repo)
? () => void create(undefined, undefined, value)
: undefined,
};
Expand Down Expand Up @@ -348,12 +363,38 @@ export function App() {
}
}

function startSetup(project: AppProject, start?: { text?: string; onCreated?: () => void }) {
if (project.repo) setSetup({ project: { ...project, repo: project.repo }, start });
}

/** Channels only start on this host; setup may clone here first, never on the other host. */
async function finishSetup(path: string) {
if (!setup) return;
const value = await host.request<Project>({
op: "project-clone",
repo: setup.project.repo,
path,
});
const id = projectId(hello.host, value.path, value.repo);
setSetup(undefined);
if (!setup.start) return chooseProject(id);
const target: AppProject = {
...value,
id,
host: hello.host,
hosts: new Set([hello.host]),
checkouts: new Set([checkoutId(hello.host, value.path)]),
};
await create(setup.start.text, setup.start.onCreated, target);
}

async function create(
text?: string,
onCreated?: () => void,
target = current,
): Promise<void> {
if (!target || target.host !== hello.host || !connected || creating.current) return;
if (!target || !connected || creating.current) return;
if (target.host !== hello.host) return startSetup(target, { text, onCreated });
creating.current = true;
try {
const value = await host.request<Pick<Listing, "id" | "project">>({
Expand Down Expand Up @@ -475,7 +516,7 @@ export function App() {
const value = listings.get(item.uid);
if (value) select(value);
}}
onNewSession={!allProjects && local && connected
onNewSession={!allProjects && creatable && connected
? () => void create()
: undefined}
onRename={connected
Expand Down Expand Up @@ -549,6 +590,7 @@ export function App() {
channels={visible}
local={local}
connected={connected}
onSetup={!local && current.repo ? () => startSetup(current) : undefined}
onProject={chooseProject}
onOpen={() => void choose()}
onChannel={select}
Expand All @@ -562,7 +604,7 @@ export function App() {
kind={page}
project={current}
connected={connected}
onCreate={local ? create : undefined}
onCreate={creatable ? create : undefined}
/>
)
: channel
Expand Down Expand Up @@ -590,27 +632,44 @@ export function App() {
Use + beside a project to start a new channel.
</p>
)}
{!allProjects && local && (
<Button
disabled={!connected}
onClick={() => void create()}
>
<IconPlus aria-hidden />New channel
</Button>
)}
{!allProjects && !local && (
<p className="text-sm text-muted-foreground">
{current.name} is checked out on{" "}
{[...current.hosts].join(", ")}. Open a checkout on this host to start
channels here.
<p className="max-w-[34rem] text-sm text-muted-foreground">
{current.name} 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."}
</p>
)}
{!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>
</div>
)}
</div>
)}
</Main>
{page === "channels" && channel && <ChannelDetails channel={channel} />}
</Layout>
{adding && <OpenProject onOpen={open} onClose={() => setAdding(false)} />}
{setup && (
<SetupProject
project={setup.project}
starting={!!setup.start}
onSetup={finishSetup}
onClose={() => setSetup(undefined)}
/>
)}
{renaming && (
<Rename
open
Expand Down
57 changes: 48 additions & 9 deletions apps/app/src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,48 @@ import {
import { IconChevronDown, IconHash, IconPlus } from "@ace/ui/icons";
import type { Listing } from "@ace/host/protocol";

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

/**
* A project's dashboard draft and history follow its identity, so setting up a GitHub project here
* keeps them. Earlier versions scoped each to one checkout; the first one found moves over once.
*/
function adopt(project: AppProject): string {
const scope = `/projects/${project.id}/dashboard`;
for (const kind of ["draft", "history"]) {
const key = `ace:${kind}:${scope}`;
try {
// useDraft still moves drafts that earlier versions kept in sessionStorage.
const stores = kind === "draft" ? [localStorage, sessionStorage] : [localStorage];
if (stores.some((store) => store.getItem(key) !== null)) continue;
for (const checkout of project.checkouts) {
const legacy = `ace:${kind}:/projects/${checkout}/dashboard`;
const store = legacy !== key && stores.find((store) => store.getItem(legacy) !== null);
if (!store) continue;
// A failed write throws before the legacy copy is removed.
store.setItem(key, store.getItem(legacy)!);
store.removeItem(legacy);
break;
}
} catch {
// Unavailable storage leaves both scopes as they were.
}
}
return scope;
}

const MODES = [{ id: "ace", name: "Ace", placeholder: "Start a new channel" }];

export function Dashboard(
{ project, repos, channels, local, connected, onProject, onOpen, onChannel, onCreate }: {
{ project, repos, channels, local, connected, onSetup, onProject, onOpen, onChannel, onCreate }: {
project: AppProject;
repos: SessionSidebarRepo[];
channels: Listing[];
local: boolean;
connected: boolean;
/** Offered for a GitHub project with no checkout on this host. */
onSetup?: () => void;
onProject: (id: string) => void;
onOpen: () => void;
onChannel: (channel: Listing) => void;
Expand All @@ -30,6 +61,8 @@ export function Dashboard(
) {
const composer = useRef<ChatComposerHandle>(null);
const [busy, setBusy] = useState(false);
const [scope] = useState(() => adopt(project));
const creatable = local || !!onSetup;
const active = channels.filter((channel) => channel.state !== "archived")
.sort((a, b) => b.created - a.created);
const hour = new Date().getHours();
Expand Down Expand Up @@ -74,17 +107,23 @@ export function Dashboard(
<p className="max-w-[34rem] text-pretty text-sm leading-[1.55] text-foreground/65">
{local
? `What would you like to work on in ${project.name}?`
: `Catch up on ${project.name}’s channels on ${
[...project.hosts].join(", ")
}. Open a checkout on this host to start channels here.`}
: `Catch up on ${project.name}’s channels on ${[...project.hosts].join(", ")}. ${
onSetup
? `Set it up on ${here} to start channels here.`
: "Open a checkout on this host to start channels here."
}`}
</p>
{onSetup && (
<Button variant="outline" disabled={!connected} onClick={onSetup}>
Set up on {here}
</Button>
)}
</section>
{local && (
{creatable && (
<div className="mx-auto mt-9 max-w-[39rem]">
<ChatComposer
ref={composer}
// Drafts saved before repository projects are scoped to this checkout.
scope={`/projects/${checkoutId(project.host, project.path)}/dashboard`}
scope={scope}
modes={MODES}
mode="ace"
mic={false}
Expand All @@ -102,7 +141,7 @@ export function Dashboard(
<section className="mx-auto mt-10 max-w-[39rem]">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-sm font-medium">{active.length ? "Pick back up" : "Channels"}</h2>
{local && (
{creatable && (
<Button
size="sm"
variant="ghost"
Expand Down
3 changes: 3 additions & 0 deletions apps/app/src/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ export function titlebar(event: MouseEvent<HTMLElement>) {
event.preventDefault();
void desktop.zoom();
}

/** The machine this app's host runs on: the desktop only runs on macOS. */
export const here = desktop ? "this Mac" : "this host";
9 changes: 7 additions & 2 deletions apps/app/src/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,14 @@ 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);

/** Names a project without a checkout on this host by the hosts that have one. */
/**
* A GitHub project is the same project wherever it is checked out. A folder without one is only
* its host's, so a folder elsewhere is named by the hosts that have it.
*/
export const label = (project: AppProject, host: string) =>
project.host === host ? project.name : `${project.name} · ${[...project.hosts].join(", ")}`;
project.repo || project.host === host
? project.name
: `${project.name} · ${[...project.hosts].join(", ")}`;

export function projects(opened: Project[], channels: Listing[], host: string): AppProject[] {
const values = new Map<string, AppProject>();
Expand Down
97 changes: 97 additions & 0 deletions apps/app/src/setup-project.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useId, useState } from "react";

import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
} from "@ace/ui";

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

/**
* Clones a project known from other hosts, 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 }: {
project: AppProject & { repo: string };
starting: boolean;
onSetup: (path: string) => Promise<void>;
onClose: () => void;
}) {
const id = useId();
// The host expands `~`, so the app never assumes where this machine's home folder is.
const [path, setPath] = useState(`~/code/${project.repo.split("/")[1]}`);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");

async function setup() {
if (busy || !path.trim()) return;
setBusy(true);
setError("");
try {
await onSetup(path.trim());
} catch (error) {
setError((error as Error).message);
setBusy(false);
}
}

return (
<Dialog
open
onOpenChange={(value) => {
// The clone keeps running on the host, so the form stays until it reports back.
if (!value && !busy) onClose();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Set up {project.name} on {here}</DialogTitle>
<DialogDescription>
{project.name} is checked out on {[...project.hosts].join(", ")}. Clone {project.repo}
{" "}
into a folder on {here}
{starting ? " to start the channel here." : "."}{" "}
A checkout of it already in that folder is opened instead.
</DialogDescription>
</DialogHeader>
<form
id={id}
onSubmit={(event) => {
event.preventDefault();
void setup();
}}
>
<label htmlFor={`${id}-path`} className="mb-1.5 block text-xs font-medium">
Folder
</label>
<Input
id={`${id}-path`}
autoFocus
value={path}
disabled={busy}
spellCheck={false}
onChange={(event) => setPath(event.target.value)}
/>
{error && (
<p role="alert" className="mt-2 whitespace-pre-wrap text-xs text-destructive">
{error}
</p>
)}
</form>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={onClose}>Cancel</Button>
<Button type="submit" form={id} disabled={busy || !path.trim()}>
{busy ? "Setting up…" : starting ? "Clone and start channel" : "Clone and open"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading