import { useCallback, useEffect, useState } from "react";
import {
documentPath,
documentsPath,
parseDocumentPath,
parseResearchWorkspacePath,
researchWorkspacePath,
} from "@chopin/protocol/document-url";
import * as Api from "./api";
import { readChannelRecovery, readDocumentRecovery, rememberChannel } from "./channel-recovery";
import { newestDocument } from "./document-actions";
import { NavigationShell, useNavigationDocument } from "./navigation-shell";
import type { ComponentType, ReactNode } from "react";
export type HostedWorkspaceProps = {
room: string;
handle: string;
label: string;
slug: string;
updatedAt: string;
descriptionRevision: number;
description?: string;
repository: Api.Repository;
canEdit: boolean;
canManage: boolean;
archivedAt?: string;
agent?: boolean;
userId: string;
};
export type HostedRoute =
| { page: "repositories" }
| { page: "repository"; owner: string; repository: string }
| { page: "document"; owner: string; repository: string; slug: string }
| {
page: "research";
owner: string;
repository: string;
slug: string;
workspaceId: 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 research = parseResearchWorkspacePath(pathname);
if (research) return { page: "research", ...research };
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 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 ChannelWorkspace(
{ agent, id, owner, repository, researchWorkspaceId, slug, user }: {
agent: boolean;
id?: string;
owner?: string;
repository?: string;
researchWorkspaceId?: string;
slug?: string;
user: Api.User;
},
) {
type LoadedWorkspace =
| {
kind: "document";
detail: Api.ChannelDetail;
Workspace: ComponentType;
}
| {
kind: "research";
detail: Api.ChannelDetail;
Workspace: ComponentType;
};
let [loaded, setLoaded] = useState();
let [error, setError] = useState();
let [retry, setRetry] = useState(0);
let { channel: navigationChannel, onDocumentLoaded } = useNavigationDocument();
let recovery = id
? readChannelRecovery(user.id, id)
: owner && repository && slug
? readDocumentRecovery(user.id, owner, repository, slug)
: undefined;
useEffect(() => {
let active = true;
let controller = new AbortController();
setLoaded(undefined);
setError(undefined);
let detail = id
? Api.channel(id, controller.signal)
: Api.document(owner!, repository!, slug!, controller.signal);
let prepared = detail.then(value => {
if (active) {
canonicalize(
researchWorkspaceId
? researchWorkspacePath(
value.repository.owner,
value.repository.name,
value.channel.slug,
researchWorkspaceId,
)
: documentPath(
value.repository.owner,
value.repository.name,
value.channel.slug,
),
);
rememberChannel(user.id, value.channel, value.repository);
void onDocumentLoaded(value.channel);
}
return value;
});
let workspace = researchWorkspaceId
? import("./research-workspace").then(module => ({
kind: "research" as const,
Workspace: module.ResearchWorkspace,
}))
: import("./room-workspace").then(module => ({
kind: "document" as const,
Workspace: module.RoomWorkspace,
}));
Promise.all([prepared, workspace]).then(([detail, selected]) => {
if (active) setLoaded({ detail, ...selected } as LoadedWorkspace);
}, reason => {
if (active) setError(reason);
});
return () => {
active = false;
controller.abort();
};
}, [
id,
onDocumentLoaded,
owner,
repository,
researchWorkspaceId,
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 } = loaded;
let channel = navigationChannel?.id === detail.channel.id
? newestDocument(detail.channel, navigationChannel)
: detail.channel;
let props: HostedWorkspaceProps = {
agent,
archivedAt: channel.archivedAt,
canEdit: !channel.archivedAt && (detail.canEdit || detail.canManage),
canManage: detail.canManage,
description: channel.description,
descriptionRevision: channel.descriptionRevision,
handle: user.login,
label: channel.title,
slug: channel.slug,
updatedAt: channel.updatedAt,
repository: detail.repository,
room: detail.channel.id,
userId: user.id,
};
if (loaded.kind === "research") {
let Research = loaded.Workspace;
return ;
}
let Document = loaded.Workspace;
return ;
}
export function HostedApp(
{ agent, user }: { agent: boolean; user: Api.User },
) {
let [route, setRoute] = useState(() => hostedRoute(location.pathname));
let navigate = useCallback((destination: string, options: { replace?: boolean } = {}) => {
let target = new URL(destination, location.href);
if (target.origin !== location.origin) {
location.assign(target.href);
return;
}
let next = `${target.pathname}${target.search}${target.hash}`;
let current = `${location.pathname}${location.search}${location.hash}`;
if (next === current) return;
if (options.replace) history.replaceState(null, "", next);
else history.pushState(null, "", next);
setRoute(hostedRoute(target.pathname));
}, []);
useEffect(() => {
let changed = () => setRoute(hostedRoute(location.pathname));
window.addEventListener("popstate", changed);
return () => window.removeEventListener("popstate", changed);
}, []);
if (route.page === "missing") {
return ;
}
let workspace: ReactNode;
switch (route.page) {
case "repositories":
case "repository":
break;
case "document":
workspace = (
);
break;
case "research":
workspace = (
);
break;
case "channel":
workspace = (
);
break;
}
return (
{workspace}
);
}
export { Failure as HostedFailure, Loading as HostedLoading };