import { useEffect, useState } from "react";
import { documentPath, documentsPath, parseDocumentPath } from "@chopin/protocol/document-url";
import * as Api from "./api";
import { readChannelRecovery, readDocumentRecovery, rememberChannel } from "./channel-recovery";
import { DocumentRename } from "./document-rename";
import { RepositoryPicker } from "./repository-picker";
import { clearRepositoryCache } from "./repository-cache";
import type { ComponentType, FormEvent, ReactNode } from "react";
export type HostedWorkspaceProps = {
room: string;
handle: string;
label: string;
slug: string;
updatedAt: string;
repository: Api.Repository;
canEdit: boolean;
agent?: boolean;
userId: string;
};
export type HostedRoute =
| { page: "repositories" }
| { page: "repository"; owner: string; repository: string }
| { page: "document"; owner: string; repository: string; slug: string }
| { page: "channel"; id: string }
| { page: "missing" };
function decoded(value: string): string | undefined {
try {
return decodeURIComponent(value);
} catch {
return undefined;
}
}
export function hostedRoute(pathname: string): HostedRoute {
if (pathname === "/" || pathname === "") return { page: "repositories" };
let document = parseDocumentPath(pathname);
if (document?.slug) {
return {
page: "document",
owner: document.owner,
repository: document.repository,
slug: document.slug,
};
}
if (document) return { page: "repository", ...document };
let repository = /^\/repositories\/([^/]+)\/([^/]+)\/?$/.exec(pathname);
if (repository) {
let owner = decoded(repository[1]!);
let name = decoded(repository[2]!);
if (owner && name) return { page: "repository", owner, repository: name };
}
let channel = /^\/channels\/([0-9a-f-]{36})\/?$/i.exec(pathname);
if (channel) return { page: "channel", id: channel[1]!.toLowerCase() };
return { page: "missing" };
}
export function retryableChannelFailure(error: unknown): boolean {
return !(error instanceof Api.ApiError)
|| error.status === 408
|| error.status === 429
|| error.status >= 500;
}
function Frame(
{
children,
currentRepository,
openRepositoryPicker = false,
user,
}: {
children: ReactNode;
currentRepository?: Api.Repository | { owner: string; name: string; fullName: string };
openRepositoryPicker?: boolean;
user: Api.User;
},
) {
async function signOut() {
await Api.logout();
clearRepositoryCache(user.id);
location.assign("/");
}
return (
);
}
function Loading({ label = "Loading" }: { label?: string }) {
return (
{label}
);
}
function repositoryHref(repository: { owner: string; name: string }): string {
return documentsPath(repository.owner, repository.name);
}
function canonicalize(pathname: string): void {
if (location.pathname === pathname) return;
history.replaceState(null, "", `${pathname}${location.search}${location.hash}`);
}
function Failure(
{
channel,
error,
onRetry,
repository,
}: {
channel?: { title?: string; slug?: string };
error: unknown;
onRetry?: () => void;
repository?: Pick;
},
) {
let message = error instanceof Error ? error.message : "Something went wrong";
return (
Cannot open Chopin
{message}
{channel && (
{channel.title &&
{channel.title}
}
{channel.slug && (
{channel.slug}
)}
{repository && (
{repository.fullName}
)}
)}
);
}
export function HostedLogin() {
let href = githubLoginHref(location.pathname, location.search, location.hash);
return (
chopin
Plan together, in the context of the code.
A shared document, a visible conversation, and an agent that can read the repository
without owning the decision.
Open your workspace
Sign in with GitHub to choose a repository and its planning channels.
Continue with GitHub
);
}
export function githubLoginHref(pathname: string, search = "", hash = ""): string {
let parameters = new URLSearchParams({ return_to: `${pathname}${search}${hash}` });
return `/auth/github?${parameters}`;
}
function RepositoryHome({ user }: { user: Api.User }) {
return (
Choose a repository from the menu to see its planning channels.
);
}
function RepositoryChannels(
{ owner, repository, user }: { owner: string; repository: string; user: Api.User },
) {
let [page, setPage] = useState();
let [error, setError] = useState();
let [title, setTitle] = useState("");
let [creating, setCreating] = useState(false);
let [loadingMore, setLoadingMore] = useState(false);
let [renaming, setRenaming] = useState();
useEffect(() => {
canonicalize(documentsPath(owner, repository));
}, [owner, repository]);
useEffect(() => {
let active = true;
Api.channels(owner, repository).then(value => {
if (active) {
canonicalize(documentsPath(value.repository.owner, value.repository.name));
setPage(value);
}
}, reason => {
if (active) setError(reason);
});
return () => {
active = false;
};
}, [owner, repository]);
async function create(event: FormEvent) {
event.preventDefault();
if (!title.trim() || creating) return;
setCreating(true);
try {
let result = await Api.createChannel(owner, repository, title.trim());
rememberChannel(user.id, result.channel, result.repository);
location.assign(documentPath(
result.repository.owner,
result.repository.name,
result.channel.slug,
));
} catch (reason) {
setError(reason);
setCreating(false);
}
}
async function more() {
let cursor = page?.nextCursor;
if (!cursor || loadingMore) return;
setLoadingMore(true);
try {
let next = await Api.channels(owner, repository, cursor);
setPage(current =>
current && {
...next,
channels: [
...current.channels,
...next.channels.filter(channel =>
!current.channels.some(known => known.id === channel.id)
),
],
}
);
} catch (reason) {
setError(reason);
} finally {
setLoadingMore(false);
}
}
function stopRenaming(id: string) {
setRenaming(undefined);
requestAnimationFrame(() => document.getElementById(`rename-channel-${id}`)?.focus());
}
function renamed(detail: Api.ChannelDetail) {
rememberChannel(user.id, detail.channel, detail.repository);
setPage(current => {
if (!current) return current;
let channels = current.channels
.map(channel => channel.id === detail.channel.id ? detail.channel : channel)
.sort((left, right) =>
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()
|| left.id.localeCompare(right.id)
);
return { ...current, channels };
});
stopRenaming(detail.channel.id);
}
if (error) return ;
return (
Planning channels
One shared plan and conversation per channel.
{page?.canEdit && (
)}
{!page
? Loading channels...
: page.channels.length === 0
? (
No planning channels yet
{page.canEdit
? "Create the first one above."
: "A repository editor can create one."}
)
: (
{page.channels.map((channel, index) => {
let corners = `${index === 0 ? "rounded-t-lg" : ""} ${
index === page.channels.length - 1 ? "rounded-b-lg" : ""
}`;
return (
{renaming === channel.id
? (
stopRenaming(channel.id)}
onRenamed={renamed}
/>
)
: (
)}
);
})}
)}
{page?.nextCursor && (
)}
);
}
function ChannelWorkspace(
{ agent, id, owner, repository, slug, user }: {
agent: boolean;
id?: string;
owner?: string;
repository?: string;
slug?: string;
user: Api.User;
},
) {
let [loaded, setLoaded] = useState<{
detail: Api.ChannelDetail;
Workspace: ComponentType;
}>();
let [error, setError] = useState();
let [retry, setRetry] = useState(0);
let recovery = id
? readChannelRecovery(user.id, id)
: owner && repository && slug
? readDocumentRecovery(user.id, owner, repository, slug)
: undefined;
useEffect(() => {
let active = true;
let detail = id
? Api.channel(id)
: Api.document(owner!, repository!, slug!);
let prepared = detail.then(value => {
if (active) {
canonicalize(documentPath(
value.repository.owner,
value.repository.name,
value.channel.slug,
));
rememberChannel(user.id, value.channel, value.repository);
}
return value;
});
Promise.all([prepared, import("./room-workspace")]).then(([detail, module]) => {
if (active) setLoaded({ detail, Workspace: module.RoomWorkspace });
}, reason => {
if (active) setError(reason);
});
return () => {
active = false;
};
}, [id, owner, repository, retry, slug, user.id]);
if (error) {
let requestedRepository = owner && repository
? { owner, name: repository, fullName: `${owner}/${repository}` }
: undefined;
return (
{
setError(undefined);
setRetry(value => value + 1);
}
: undefined}
repository={recovery?.repository ?? requestedRepository}
/>
);
}
if (!loaded) return ;
let { detail, Workspace } = loaded;
return (
);
}
export function HostedApp(
{ agent, user }: { agent: boolean; user: Api.User },
) {
let route = hostedRoute(location.pathname);
switch (route.page) {
case "repositories":
return ;
case "repository":
return ;
case "document":
return (
);
case "channel":
return ;
case "missing":
return ;
}
}
export { Failure as HostedFailure, Loading as HostedLoading };