refactor projects page
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
import { api } from "@code/backend/convex/_generated/api";
|
||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import { useMutation, useQuery } from "convex/react";
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const ContextEditor = ({
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
readonly projectId: Id<"projects">;
|
||||||
|
}) => {
|
||||||
|
const instructions = useQuery(api.projects.getInstructions, {
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
const updateInstructions = useMutation(api.projects.updateInstructions);
|
||||||
|
const [draft, setDraft] = useState<string>();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const currentDraft = draft ?? instructions ?? "";
|
||||||
|
const dirty = draft !== undefined && draft !== (instructions ?? "");
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!dirty || saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateInstructions({
|
||||||
|
instructions: draft ?? "",
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
setDraft(undefined);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<label
|
||||||
|
className="text-xs font-medium text-[#20201d]"
|
||||||
|
htmlFor="context-textarea"
|
||||||
|
>
|
||||||
|
Context
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="mt-1 h-24 w-full resize-y border border-[#c9c5b9] bg-[#fffefa] p-2 text-xs outline-none focus:border-[#55564e]"
|
||||||
|
id="context-textarea"
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
placeholder="Project-specific instructions for agents..."
|
||||||
|
value={currentDraft}
|
||||||
|
/>
|
||||||
|
{dirty ? (
|
||||||
|
<Button
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={save}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{saving ? <LoaderCircle className="size-3 animate-spin" /> : null}
|
||||||
|
{saving ? "Saving" : "Save context"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { FolderGit2 } from "lucide-react";
|
||||||
|
|
||||||
|
export const EmptyProjects = () => (
|
||||||
|
<div className="mt-12 text-center">
|
||||||
|
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
|
||||||
|
<FolderGit2 className="size-5" />
|
||||||
|
</span>
|
||||||
|
<h1 className="mt-6 text-2xl font-semibold text-[#20201d]">
|
||||||
|
Connect your first project
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-[#68665e]">
|
||||||
|
Choose a Git provider below to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { authClient } from "@code/auth/web";
|
||||||
|
import { LoaderCircle, GitBranch } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const GitHubConnectButton = () => {
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const linkGithub = async () => {
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await authClient.linkSocial({
|
||||||
|
callbackURL: `${window.location.origin}/projects?resume=github`,
|
||||||
|
provider: "github",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
||||||
|
onClick={linkGithub}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<GitBranch className="size-5 text-[#20201d]" />
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="text-sm font-semibold text-[#20201d]">GitHub</p>
|
||||||
|
<p className="text-xs text-[#747168]">OAuth with private repo access</p>
|
||||||
|
</div>
|
||||||
|
{pending ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
|
export const LoadingState = () => (
|
||||||
|
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
||||||
|
<LoaderCircle className="size-5 animate-spin text-[#20201d]" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { FileText, FolderGit2 } from "lucide-react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
export const ProjectCard = ({
|
||||||
|
instructions,
|
||||||
|
name,
|
||||||
|
projectId,
|
||||||
|
sourceUrl,
|
||||||
|
}: {
|
||||||
|
readonly instructions: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly sourceUrl: string;
|
||||||
|
}) => (
|
||||||
|
<Link
|
||||||
|
className="block border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
||||||
|
to={`/?project=${projectId}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderGit2 className="size-4 text-[#747168]" />
|
||||||
|
<h3 className="text-sm font-semibold text-[#20201d]">{name}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-xs text-[#747168]">{sourceUrl}</p>
|
||||||
|
{instructions ? (
|
||||||
|
<div className="mt-2 flex items-center gap-1 text-xs text-[#747168]">
|
||||||
|
<FileText className="size-3" />
|
||||||
|
<span className="truncate">Context configured</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
import { authClient } from "@code/auth/web";
|
|
||||||
import { api } from "@code/backend/convex/_generated/api";
|
|
||||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
|
||||||
import { Button } from "@code/ui/components/button";
|
|
||||||
import { useAction, useMutation, useQuery } from "convex/react";
|
|
||||||
import { makeFunctionReference } from "convex/server";
|
|
||||||
import {
|
|
||||||
FileText,
|
|
||||||
FolderGit2,
|
|
||||||
GitBranch,
|
|
||||||
LoaderCircle,
|
|
||||||
Server,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
|
||||||
|
|
||||||
const connectGithubRef = makeFunctionReference<
|
|
||||||
"action",
|
|
||||||
Record<string, never>,
|
|
||||||
{ connectionId: Id<"gitConnections"> }
|
|
||||||
>("gitConnections:connectGithub");
|
|
||||||
const connectGiteaRef = makeFunctionReference<
|
|
||||||
"action",
|
|
||||||
{ token: string; username?: string },
|
|
||||||
{ connectionId: Id<"gitConnections"> }
|
|
||||||
>("gitConnections:connectGitea");
|
|
||||||
const createProjectFromRepositoryRef = makeFunctionReference<
|
|
||||||
"mutation",
|
|
||||||
{ gitRepositoryId: Id<"gitRepositories">; name: string },
|
|
||||||
{ projectId: Id<"projects"> }
|
|
||||||
>("gitProvisioning:createProjectFromRepository");
|
|
||||||
|
|
||||||
const LoadingState = () => (
|
|
||||||
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
|
||||||
<LoaderCircle className="size-5 animate-spin text-[#20201d]" />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
|
|
||||||
const EmptyProjects = () => (
|
|
||||||
<div className="mt-12 text-center">
|
|
||||||
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
|
|
||||||
<FolderGit2 className="size-5" />
|
|
||||||
</span>
|
|
||||||
<h1 className="mt-6 text-2xl font-semibold text-[#20201d]">
|
|
||||||
Connect your first project
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-[#68665e]">
|
|
||||||
Choose a Git provider below to get started.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ProjectCard = ({
|
|
||||||
instructions,
|
|
||||||
name,
|
|
||||||
projectId,
|
|
||||||
sourceUrl,
|
|
||||||
}: {
|
|
||||||
instructions: string;
|
|
||||||
name: string;
|
|
||||||
projectId: string;
|
|
||||||
sourceUrl: string;
|
|
||||||
}) => (
|
|
||||||
<Link
|
|
||||||
className="block border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
|
||||||
to={`/?project=${projectId}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FolderGit2 className="size-4 text-[#747168]" />
|
|
||||||
<h3 className="text-sm font-semibold text-[#20201d]">{name}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 truncate text-xs text-[#747168]">{sourceUrl}</p>
|
|
||||||
{instructions ? (
|
|
||||||
<div className="mt-2 flex items-center gap-1 text-xs text-[#747168]">
|
|
||||||
<FileText className="size-3" />
|
|
||||||
<span className="truncate">Context configured</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ContextEditor = ({
|
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
readonly projectId: Id<"projects">;
|
|
||||||
}) => {
|
|
||||||
const instructions = useQuery(api.projects.getInstructions, {
|
|
||||||
projectId,
|
|
||||||
});
|
|
||||||
const updateInstructions = useMutation(api.projects.updateInstructions);
|
|
||||||
const [draft, setDraft] = useState<string>();
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const currentDraft = draft ?? instructions ?? "";
|
|
||||||
const dirty = draft !== undefined && draft !== (instructions ?? "");
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (!dirty || saving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await updateInstructions({
|
|
||||||
instructions: draft ?? "",
|
|
||||||
projectId,
|
|
||||||
});
|
|
||||||
setDraft(undefined);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-3">
|
|
||||||
<label
|
|
||||||
className="text-xs font-medium text-[#20201d]"
|
|
||||||
htmlFor="context-textarea"
|
|
||||||
>
|
|
||||||
Context
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="mt-1 h-24 w-full resize-y border border-[#c9c5b9] bg-[#fffefa] p-2 text-xs outline-none focus:border-[#55564e]"
|
|
||||||
id="context-textarea"
|
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
|
||||||
placeholder="Project-specific instructions for agents..."
|
|
||||||
value={currentDraft}
|
|
||||||
/>
|
|
||||||
{dirty ? (
|
|
||||||
<Button
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={save}
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{saving ? <LoaderCircle className="size-3 animate-spin" /> : null}
|
|
||||||
{saving ? "Saving" : "Save context"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PuterConnectForm = ({
|
|
||||||
onConnected,
|
|
||||||
}: {
|
|
||||||
readonly onConnected: () => void;
|
|
||||||
}) => {
|
|
||||||
const connectGitea = useAction(connectGiteaRef);
|
|
||||||
const [token, setToken] = useState("");
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
const [error, setError] = useState<string>();
|
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!token.trim() || pending) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPending(true);
|
|
||||||
setError(undefined);
|
|
||||||
try {
|
|
||||||
await connectGitea({
|
|
||||||
token: token.trim(),
|
|
||||||
username: username.trim() || undefined,
|
|
||||||
});
|
|
||||||
setToken("");
|
|
||||||
setUsername("");
|
|
||||||
onConnected();
|
|
||||||
} catch (caughtError) {
|
|
||||||
setError(
|
|
||||||
caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="mt-4 space-y-3" onSubmit={submit}>
|
|
||||||
<p className="text-xs text-[#68665e]">
|
|
||||||
Connect your Puter Git personal access token. The Puter Git instance is
|
|
||||||
at <code className="text-[#20201d]">git.openputer.com</code>.
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
|
||||||
placeholder="Puter username (optional)"
|
|
||||||
value={username}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
|
||||||
onChange={(event) => setToken(event.target.value)}
|
|
||||||
placeholder="Personal access token"
|
|
||||||
required
|
|
||||||
type="password"
|
|
||||||
value={token}
|
|
||||||
/>
|
|
||||||
{error ? <p className="text-xs text-red-700">{error}</p> : null}
|
|
||||||
<Button className="h-10 w-full" disabled={pending} type="submit">
|
|
||||||
{pending ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
|
||||||
{pending ? "Connecting" : "Connect Puter Git"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const RepositorySelector = ({
|
|
||||||
onProjectCreated,
|
|
||||||
}: {
|
|
||||||
readonly onProjectCreated: (projectId: string) => void;
|
|
||||||
}) => {
|
|
||||||
const repositories = useQuery(
|
|
||||||
makeFunctionReference<
|
|
||||||
"query",
|
|
||||||
Record<string, never>,
|
|
||||||
readonly {
|
|
||||||
cloneUrl: string;
|
|
||||||
defaultBranch: string;
|
|
||||||
fullName: string;
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
owner: string;
|
|
||||||
privacy: "public" | "private";
|
|
||||||
provider: "github" | "gitea";
|
|
||||||
webUrl: string;
|
|
||||||
}[]
|
|
||||||
>("gitProvisioning:listRepositories"),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
const createProject = useMutation(createProjectFromRepositoryRef);
|
|
||||||
const [pending, setPending] = useState<string>();
|
|
||||||
|
|
||||||
const create = async (repoId: string, name: string) => {
|
|
||||||
setPending(repoId);
|
|
||||||
try {
|
|
||||||
const result = await createProject({
|
|
||||||
gitRepositoryId: repoId as Id<"gitRepositories">,
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
onProjectCreated(String(result.projectId));
|
|
||||||
} finally {
|
|
||||||
setPending(undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (repositories === undefined) {
|
|
||||||
return <LoaderCircle className="size-4 animate-spin text-[#747168]" />;
|
|
||||||
}
|
|
||||||
if (repositories.length === 0) {
|
|
||||||
return (
|
|
||||||
<p className="text-xs text-[#747168]">
|
|
||||||
No repositories found. Create one on your provider first, or import a
|
|
||||||
public repository below.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{repositories.map((repo) => (
|
|
||||||
<button
|
|
||||||
className="flex w-full items-center justify-between border border-[#d7d3c7] bg-[#fffefa] p-3 text-left transition-colors hover:bg-[#f5f3eb]"
|
|
||||||
key={repo.id}
|
|
||||||
onClick={() => create(repo.id, repo.name)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[#20201d]">
|
|
||||||
{repo.fullName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[#747168]">
|
|
||||||
{repo.provider} - {repo.defaultBranch}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{pending === repo.id ? (
|
|
||||||
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GitHubConnectButton = () => {
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
|
|
||||||
const linkGithub = async () => {
|
|
||||||
setPending(true);
|
|
||||||
try {
|
|
||||||
await authClient.linkSocial({
|
|
||||||
callbackURL: `${window.location.origin}/projects?resume=github`,
|
|
||||||
provider: "github",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
|
||||||
onClick={linkGithub}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<GitBranch className="size-5 text-[#20201d]" />
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<p className="text-sm font-semibold text-[#20201d]">GitHub</p>
|
|
||||||
<p className="text-xs text-[#747168]">OAuth with private repo access</p>
|
|
||||||
</div>
|
|
||||||
{pending ? (
|
|
||||||
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProviderSection = () => {
|
|
||||||
const [showPuterForm, setShowPuterForm] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="mt-10">
|
|
||||||
<h2 className="text-sm font-semibold text-[#20201d]">Git providers</h2>
|
|
||||||
<p className="mt-1 text-xs text-[#68665e]">
|
|
||||||
Connect a repository to create a Project.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
<GitHubConnectButton />
|
|
||||||
<button
|
|
||||||
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
|
||||||
onClick={() => setShowPuterForm((v) => !v)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Server className="size-5 text-[#20201d]" />
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<p className="text-sm font-semibold text-[#20201d]">Puter Git</p>
|
|
||||||
<p className="text-xs text-[#747168]">
|
|
||||||
Connect with a personal access token
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showPuterForm ? (
|
|
||||||
<PuterConnectForm onConnected={() => setShowPuterForm(false)} />
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const RepositorySection = ({
|
|
||||||
onProjectCreated,
|
|
||||||
}: {
|
|
||||||
readonly onProjectCreated: (projectId: string) => void;
|
|
||||||
}) => (
|
|
||||||
<section className="mt-6">
|
|
||||||
<h2 className="text-sm font-semibold text-[#20201d]">Your repositories</h2>
|
|
||||||
<p className="mt-1 text-xs text-[#68665e]">
|
|
||||||
Select a repository to create a Project.
|
|
||||||
</p>
|
|
||||||
<RepositorySelector onProjectCreated={onProjectCreated} />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ProjectsPage = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const projects = useQuery(api.projects.list, {});
|
|
||||||
const connectGithubAction = useAction(connectGithubRef);
|
|
||||||
const [resumeError, setResumeError] = useState<string>();
|
|
||||||
|
|
||||||
// Handle GitHub OAuth callback resume
|
|
||||||
useEffect(() => {
|
|
||||||
const resume = searchParams.get("resume");
|
|
||||||
if (resume !== "github") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await connectGithubAction({});
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
} catch (caughtError) {
|
|
||||||
setResumeError(
|
|
||||||
caughtError instanceof Error
|
|
||||||
? caughtError.message
|
|
||||||
: "GitHub connection failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [searchParams, setSearchParams, connectGithubAction]);
|
|
||||||
|
|
||||||
if (projects === undefined) {
|
|
||||||
return <LoadingState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasProjects = projects.length > 0;
|
|
||||||
const handleProjectCreated = (projectId: string) => {
|
|
||||||
void navigate(`/?project=${projectId}`, { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-svh bg-[#f2f0e7] px-5 py-8 text-[#20201d]">
|
|
||||||
<div className="mx-auto max-w-2xl">
|
|
||||||
<header className="flex items-center justify-between">
|
|
||||||
<h1 className="text-lg font-semibold">Projects</h1>
|
|
||||||
{hasProjects ? (
|
|
||||||
<Button
|
|
||||||
className="h-9"
|
|
||||||
onClick={() => navigate("/")}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Home
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{resumeError ? (
|
|
||||||
<p className="mt-4 border border-red-300 bg-red-50 p-3 text-xs text-red-700">
|
|
||||||
{resumeError}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{hasProjects ? null : <EmptyProjects />}
|
|
||||||
|
|
||||||
{hasProjects ? (
|
|
||||||
<section className="mt-6 grid gap-3">
|
|
||||||
{projects.map((project) => (
|
|
||||||
<div key={project.id}>
|
|
||||||
<ProjectCard
|
|
||||||
instructions={
|
|
||||||
project.contextDocuments.find(
|
|
||||||
(doc) => doc.kind === "readme"
|
|
||||||
)?.content ?? ""
|
|
||||||
}
|
|
||||||
name={project.name}
|
|
||||||
projectId={project.id}
|
|
||||||
sourceUrl={project.sources[0]?.url ?? ""}
|
|
||||||
/>
|
|
||||||
<ContextEditor
|
|
||||||
projectId={project.id as unknown as Id<"projects">}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<RepositorySection onProjectCreated={handleProjectCreated} />
|
|
||||||
<ProviderSection />
|
|
||||||
|
|
||||||
<section className="mt-8">
|
|
||||||
<p className="text-xs text-[#68665e]">
|
|
||||||
Or import a public repository:
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
className="mt-2 inline-flex items-center gap-2 text-sm text-[#20201d] underline"
|
|
||||||
to="/?import=public"
|
|
||||||
>
|
|
||||||
Import public Git URL
|
|
||||||
</Link>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Server } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { GitHubConnectButton } from "./github-connect-button";
|
||||||
|
import { PuterConnectForm } from "./puter-connect-form";
|
||||||
|
|
||||||
|
export const ProviderSection = () => {
|
||||||
|
const [showPuterForm, setShowPuterForm] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-10">
|
||||||
|
<h2 className="text-sm font-semibold text-[#20201d]">Git providers</h2>
|
||||||
|
<p className="mt-1 text-xs text-[#68665e]">
|
||||||
|
Connect a repository to create a Project.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<GitHubConnectButton />
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
||||||
|
onClick={() => setShowPuterForm((value) => !value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Server className="size-5 text-[#20201d]" />
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="text-sm font-semibold text-[#20201d]">Puter Git</p>
|
||||||
|
<p className="text-xs text-[#747168]">
|
||||||
|
Connect with a personal access token
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showPuterForm ? (
|
||||||
|
<PuterConnectForm onConnected={() => setShowPuterForm(false)} />
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import { useAction } from "convex/react";
|
||||||
|
import { makeFunctionReference } from "convex/server";
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface ConnectGiteaResult {
|
||||||
|
connectionId: Id<"gitConnections">;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectGiteaRef = makeFunctionReference<
|
||||||
|
"action",
|
||||||
|
{ token: string; username?: string },
|
||||||
|
ConnectGiteaResult
|
||||||
|
>("gitConnections:connectGitea");
|
||||||
|
|
||||||
|
export const PuterConnectForm = ({
|
||||||
|
onConnected,
|
||||||
|
}: {
|
||||||
|
readonly onConnected: () => void;
|
||||||
|
}) => {
|
||||||
|
const connectGitea = useAction(connectGiteaRef);
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token.trim() || pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
await connectGitea({
|
||||||
|
token: token.trim(),
|
||||||
|
username: username.trim() || undefined,
|
||||||
|
});
|
||||||
|
setToken("");
|
||||||
|
setUsername("");
|
||||||
|
onConnected();
|
||||||
|
} catch (caughtError) {
|
||||||
|
setError(
|
||||||
|
caughtError instanceof Error ? caughtError.message : String(caughtError)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="mt-4 space-y-3" onSubmit={submit}>
|
||||||
|
<p className="text-xs text-[#68665e]">
|
||||||
|
Connect your Puter Git personal access token. The Puter Git instance is
|
||||||
|
at <code className="text-[#20201d]">git.openputer.com</code>.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="Puter username (optional)"
|
||||||
|
value={username}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||||
|
onChange={(event) => setToken(event.target.value)}
|
||||||
|
placeholder="Personal access token"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
/>
|
||||||
|
{error ? <p className="text-xs text-red-700">{error}</p> : null}
|
||||||
|
<Button className="h-10 w-full" disabled={pending} type="submit">
|
||||||
|
{pending ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||||
|
{pending ? "Connecting" : "Connect Puter Git"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { RepositorySelector } from "./repository-selector";
|
||||||
|
|
||||||
|
export const RepositorySection = ({
|
||||||
|
onProjectCreated,
|
||||||
|
}: {
|
||||||
|
readonly onProjectCreated: (projectId: string) => void;
|
||||||
|
}) => (
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="text-sm font-semibold text-[#20201d]">Your repositories</h2>
|
||||||
|
<p className="mt-1 text-xs text-[#68665e]">
|
||||||
|
Select a repository to create a Project.
|
||||||
|
</p>
|
||||||
|
<RepositorySelector onProjectCreated={onProjectCreated} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import { useMutation, useQuery } from "convex/react";
|
||||||
|
import { makeFunctionReference } from "convex/server";
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const listRepositoriesRef = makeFunctionReference<
|
||||||
|
"query",
|
||||||
|
Record<string, never>,
|
||||||
|
readonly {
|
||||||
|
cloneUrl: string;
|
||||||
|
defaultBranch: string;
|
||||||
|
fullName: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
owner: string;
|
||||||
|
privacy: "public" | "private";
|
||||||
|
provider: "github" | "gitea";
|
||||||
|
webUrl: string;
|
||||||
|
}[]
|
||||||
|
>("gitProvisioning:listRepositories");
|
||||||
|
|
||||||
|
interface CreateProjectResult {
|
||||||
|
projectId: Id<"projects">;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProjectFromRepositoryRef = makeFunctionReference<
|
||||||
|
"mutation",
|
||||||
|
{ gitRepositoryId: Id<"gitRepositories">; name: string },
|
||||||
|
CreateProjectResult
|
||||||
|
>("gitProvisioning:createProjectFromRepository");
|
||||||
|
|
||||||
|
export const RepositorySelector = ({
|
||||||
|
onProjectCreated,
|
||||||
|
}: {
|
||||||
|
readonly onProjectCreated: (projectId: string) => void;
|
||||||
|
}) => {
|
||||||
|
const repositories = useQuery(listRepositoriesRef, {});
|
||||||
|
const createProject = useMutation(createProjectFromRepositoryRef);
|
||||||
|
const [pending, setPending] = useState<string>();
|
||||||
|
|
||||||
|
const create = async (repoId: string, name: string) => {
|
||||||
|
setPending(repoId);
|
||||||
|
try {
|
||||||
|
const result = await createProject({
|
||||||
|
gitRepositoryId: repoId as Id<"gitRepositories">,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
onProjectCreated(String(result.projectId));
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (repositories === undefined) {
|
||||||
|
return <LoaderCircle className="size-4 animate-spin text-[#747168]" />;
|
||||||
|
}
|
||||||
|
if (repositories.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-[#747168]">
|
||||||
|
No repositories found. Create one on your provider first, or import a
|
||||||
|
public repository below.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center justify-between border border-[#d7d3c7] bg-[#fffefa] p-3 text-left transition-colors hover:bg-[#f5f3eb]"
|
||||||
|
key={repo.id}
|
||||||
|
onClick={() => create(repo.id, repo.name)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[#20201d]">
|
||||||
|
{repo.fullName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#747168]">
|
||||||
|
{repo.provider} - {repo.defaultBranch}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{pending === repo.id ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,121 @@
|
|||||||
import { ProjectsPage } from "@/components/projects/projects-page";
|
import { api } from "@code/backend/convex/_generated/api";
|
||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import { useAction, useQuery } from "convex/react";
|
||||||
|
import { makeFunctionReference } from "convex/server";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||||
|
|
||||||
|
import { ContextEditor } from "@/components/projects/context-editor";
|
||||||
|
import { EmptyProjects } from "@/components/projects/empty-projects";
|
||||||
|
import { LoadingState } from "@/components/projects/loading-state";
|
||||||
|
import { ProjectCard } from "@/components/projects/project-card";
|
||||||
|
import { ProviderSection } from "@/components/projects/provider-section";
|
||||||
|
import { RepositorySection } from "@/components/projects/repository-section";
|
||||||
|
|
||||||
|
const connectGithubRef = makeFunctionReference<
|
||||||
|
"action",
|
||||||
|
Record<string, never>,
|
||||||
|
{ connectionId: Id<"gitConnections"> }
|
||||||
|
>("gitConnections:connectGithub");
|
||||||
|
|
||||||
export default function ProjectsRoute() {
|
export default function ProjectsRoute() {
|
||||||
return <ProjectsPage />;
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const projects = useQuery(api.projects.list, {});
|
||||||
|
const connectGithubAction = useAction(connectGithubRef);
|
||||||
|
const [resumeError, setResumeError] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resume = searchParams.get("resume");
|
||||||
|
if (resume !== "github") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await connectGithubAction({});
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
} catch (caughtError) {
|
||||||
|
setResumeError(
|
||||||
|
caughtError instanceof Error
|
||||||
|
? caughtError.message
|
||||||
|
: "GitHub connection failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [searchParams, setSearchParams, connectGithubAction]);
|
||||||
|
|
||||||
|
if (projects === undefined) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasProjects = projects.length > 0;
|
||||||
|
const handleProjectCreated = (projectId: string) => {
|
||||||
|
void navigate(`/?project=${projectId}`, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-svh bg-[#f2f0e7] px-5 py-8 text-[#20201d]">
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<h1 className="text-lg font-semibold">Projects</h1>
|
||||||
|
{hasProjects ? (
|
||||||
|
<Button
|
||||||
|
className="h-9"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{resumeError ? (
|
||||||
|
<p className="mt-4 border border-red-300 bg-red-50 p-3 text-xs text-red-700">
|
||||||
|
{resumeError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasProjects ? null : <EmptyProjects />}
|
||||||
|
|
||||||
|
{hasProjects ? (
|
||||||
|
<section className="mt-6 grid gap-3">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<div key={project.id}>
|
||||||
|
<ProjectCard
|
||||||
|
instructions={
|
||||||
|
project.contextDocuments.find(
|
||||||
|
(document) => document.kind === "readme"
|
||||||
|
)?.content ?? ""
|
||||||
|
}
|
||||||
|
name={project.name}
|
||||||
|
projectId={project.id}
|
||||||
|
sourceUrl={project.sources[0]?.url ?? ""}
|
||||||
|
/>
|
||||||
|
<ContextEditor
|
||||||
|
projectId={project.id as unknown as Id<"projects">}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<RepositorySection onProjectCreated={handleProjectCreated} />
|
||||||
|
<ProviderSection />
|
||||||
|
|
||||||
|
<section className="mt-8">
|
||||||
|
<p className="text-xs text-[#68665e]">
|
||||||
|
Or import a public repository:
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
className="mt-2 inline-flex items-center gap-2 text-sm text-[#20201d] underline"
|
||||||
|
to="/?import=public"
|
||||||
|
>
|
||||||
|
Import public Git URL
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-9
@@ -54,22 +54,22 @@
|
|||||||
"dev:tailscale": "concurrently --names engine,runner,server,agents,web --prefix-colors yellow,blue,magenta,green,cyan --handle-input --kill-others-on-fail \"pnpm run dev:engine\" \"pnpm run dev:runner\" \"pnpm run dev:server\" \"pnpm run dev:agents\" \"pnpm run dev:tailscale:web\"",
|
"dev:tailscale": "concurrently --names engine,runner,server,agents,web --prefix-colors yellow,blue,magenta,green,cyan --handle-input --kill-others-on-fail \"pnpm run dev:engine\" \"pnpm run dev:runner\" \"pnpm run dev:server\" \"pnpm run dev:agents\" \"pnpm run dev:tailscale:web\"",
|
||||||
"build": "vp run -r build",
|
"build": "vp run -r build",
|
||||||
"check-types": "vp run -r check-types",
|
"check-types": "vp run -r check-types",
|
||||||
"check": "ultracite check package.json vite.config.ts apps/web/package.json apps/web/src/root.tsx apps/web/src/index.css apps/web/src/components/chat apps/web/src/components/workspace apps/web/src/hooks/chat apps/web/src/hooks/workspace apps/web/src/lib/chat apps/web/src/lib/workspace apps/web/src/routes.ts apps/web/src/routes/app/dashboard/page.tsx apps/web/src/routes/app/workspace/page.tsx packages/agents/package.json packages/agents/src/agents/zopu.ts packages/agents/src/app.ts packages/agents/src/auth.ts packages/agents/src/tools/slice-one.ts packages/backend/package.json packages/backend/convex/conversationMessages.ts packages/backend/convex/conversationMessages.test.ts packages/backend/convex/fluePersistence.test.ts packages/backend/convex/projects.ts",
|
|
||||||
"lint": "oxlint --disable-nested-config",
|
"lint": "oxlint --disable-nested-config",
|
||||||
"format": "vp fmt",
|
"format": "vp fmt",
|
||||||
"staged": "vp staged",
|
"staged": "vp staged",
|
||||||
"hooks:setup": "vp config",
|
"hooks:setup": "vp config",
|
||||||
"dev:web": "pnpm --filter web run dev",
|
"dev:web": "vp --filter web run dev",
|
||||||
"dev:agents": "pnpm --filter @code/agents run dev",
|
"dev:agents": "vp --filter @code/agents run dev",
|
||||||
"dev:tailscale:web": "pnpm --filter web run dev:tailscale",
|
"dev:tailscale:web": "vp --filter web run dev:tailscale",
|
||||||
"dev:tailscale:agents": "pnpm --filter @code/agents run dev:tailscale",
|
"dev:tailscale:agents": "vp --filter @code/agents run dev:tailscale",
|
||||||
"dev:server": "pnpm --filter @code/backend run dev",
|
"dev:server": "vp --filter @code/backend run dev",
|
||||||
"dev:setup": "pnpm --filter @code/backend run dev:setup",
|
"dev:setup": "vp --filter @code/backend run dev:setup",
|
||||||
"dev:engine": "pnpm --filter @code/agents run dev:engine",
|
"dev:engine": "vp --filter @code/agents run dev:engine",
|
||||||
"dev:runner": "node scripts/wait-for-port.mjs 127.0.0.1 6420 && pnpm --filter @code/agents run runner",
|
"dev:runner": "node scripts/wait-for-port.mjs 127.0.0.1 6420 && vp --filter @code/agents run runner",
|
||||||
"build:agents": "vp run --filter @code/agents build",
|
"build:agents": "vp run --filter @code/agents build",
|
||||||
"docs:update": "node scripts/update-docs.ts",
|
"docs:update": "node scripts/update-docs.ts",
|
||||||
"subtree": "node scripts/subtree.ts",
|
"subtree": "node scripts/subtree.ts",
|
||||||
|
"check": "ultracite check",
|
||||||
"fix": "ultracite fix"
|
"fix": "ultracite fix"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user