From 6e3752de9faab417aa7963bf263c433adf272a66 Mon Sep 17 00:00:00 2001 From: Durga Ghimeray <152849649+durga710@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:59:19 -0400 Subject: [PATCH 01/13] feat: team signup with invite codes + member-aware dashboard auth (#1) Teammates can now create accounts with an invite code from AUTH_TEAM_INVITE_CODES (comma-separated). Sign-up provisions a local User row immediately, which doubles as the membership record; sign-in and requireUser() authorize the owner email or any enrolled member instead of a single hardcoded operator email. - auth-policy: getTeamInviteCodes / isValidTeamInviteCode / canEnrollWithInvite helpers - auth: isAuthorizedDashboardAuthUser() permits owner email or an existing local User row; getOptionalUser drops the owner-only gate - actions: sign-in authorizes after authentication and signs out unauthorized sessions; sign-up gates on owner email OR invite code and upserts the membership row - middleware: protected routes check authentication only (membership needs the DB and is enforced by requireUser); /sign-in?error=... stays reachable for authenticated sessions to avoid redirect loops - new edge-safe auth-route-policy helper + unit tests (node:test/tsx) - sign-in/sign-up copy updated for workspace/team access https://claude.ai/code/session_013M57ExYGVz7k4pFXNMTBL9 Co-authored-by: Claude --- package.json | 1 + src/app/auth/actions.ts | 47 ++++++--- src/app/sign-in/[[...sign-in]]/page.tsx | 6 +- src/app/sign-up/[[...sign-up]]/page.tsx | 24 ++++- src/lib/auth-policy.ts | 27 ++++++ src/lib/auth-route-policy.ts | 37 +++++++ src/lib/auth.ts | 27 +++++- src/middleware.ts | 35 +++---- tests/auth-policy.test.ts | 122 ++++++++++++++++++++++++ tests/auth-route-policy.test.ts | 78 +++++++++++++++ 10 files changed, 365 insertions(+), 39 deletions(-) create mode 100644 src/lib/auth-route-policy.ts create mode 100644 tests/auth-policy.test.ts create mode 100644 tests/auth-route-policy.test.ts diff --git a/package.json b/package.json index 6d0c977..fd3392f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "prisma generate && next build", "start": "next start", "lint": "eslint .", + "test": "node --import tsx --test tests/*.test.ts", "type-check": "tsc --noEmit", "db:push": "prisma db push", "db:migrate": "prisma migrate dev", diff --git a/src/app/auth/actions.ts b/src/app/auth/actions.ts index b1b78f4..a88c1ac 100644 --- a/src/app/auth/actions.ts +++ b/src/app/auth/actions.ts @@ -2,16 +2,19 @@ import { redirect } from "next/navigation"; import { z } from "zod"; +import { isAuthorizedDashboardAuthUser } from "@/lib/auth"; import { - isAllowedOperatorEmail, + canEnrollWithInvite, normalizeEmail, safeRedirectPath, } from "@/lib/auth-policy"; +import { prisma } from "@/lib/prisma"; import { createSupabaseServerClient } from "@/lib/supabase/server"; const emailSchema = z.string().trim().email().max(320); const passwordSchema = z.string().min(8).max(128); const nameSchema = z.string().trim().max(80).optional(); +const inviteCodeSchema = z.string().trim().max(120).optional(); function stringValue(formData: FormData, key: string): string { const value = formData.get(key); @@ -43,20 +46,23 @@ export async function signInWithPasswordAction(formData: FormData) { const email = normalizeEmail(emailResult.data); - if (!isAllowedOperatorEmail(email)) { - redirectToAuthError("/sign-in", "not-allowed", next); - } - const supabase = await getSupabaseOrRedirect("/sign-in", next); - const { error } = await supabase.auth.signInWithPassword({ + const { data, error } = await supabase.auth.signInWithPassword({ email, password: passwordResult.data, }); - if (error) { + if (error || !data.user) { redirectToAuthError("/sign-in", "invalid-credentials", next); } + // Authorization happens after authentication: the owner email or an + // enrolled team member (existing local User row) may enter the dashboard. + if (!(await isAuthorizedDashboardAuthUser(data.user))) { + await supabase.auth.signOut(); + redirectToAuthError("/sign-in", "not-allowed", next); + } + redirect(next); } @@ -66,12 +72,14 @@ export async function signUpWithPasswordAction(formData: FormData) { const passwordResult = passwordSchema.safeParse(stringValue(formData, "password")); const firstNameResult = nameSchema.safeParse(stringValue(formData, "firstName")); const lastNameResult = nameSchema.safeParse(stringValue(formData, "lastName")); + const inviteCodeResult = inviteCodeSchema.safeParse(stringValue(formData, "inviteCode")); if ( !emailResult.success || !passwordResult.success || !firstNameResult.success || - !lastNameResult.success + !lastNameResult.success || + !inviteCodeResult.success ) { redirectToAuthError("/sign-up", "invalid-signup", next); } @@ -80,8 +88,8 @@ export async function signUpWithPasswordAction(formData: FormData) { const firstName = firstNameResult.data || undefined; const lastName = lastNameResult.data || undefined; - if (!isAllowedOperatorEmail(email)) { - redirectToAuthError("/sign-up", "not-allowed", next); + if (!canEnrollWithInvite(email, inviteCodeResult.data)) { + redirectToAuthError("/sign-up", "invalid-invite", next); } const supabase = await getSupabaseOrRedirect("/sign-up", next); @@ -97,7 +105,24 @@ export async function signUpWithPasswordAction(formData: FormData) { }, }); - if (error) { + if (error || !data.user) { + redirectToAuthError("/sign-up", "signup-failed", next); + } + + // Record membership now, while the invite code is in hand. Sign-in (and + // email confirmation later) authorizes against this local User row. + try { + await prisma.user.upsert({ + where: { email }, + update: {}, + create: { + clerkId: data.user.id, + email, + firstName: firstName ?? null, + lastName: lastName ?? null, + }, + }); + } catch { redirectToAuthError("/sign-up", "signup-failed", next); } diff --git a/src/app/sign-in/[[...sign-in]]/page.tsx b/src/app/sign-in/[[...sign-in]]/page.tsx index 7bd226f..3806662 100644 --- a/src/app/sign-in/[[...sign-in]]/page.tsx +++ b/src/app/sign-in/[[...sign-in]]/page.tsx @@ -11,7 +11,7 @@ type AuthSearchParams = Promise<{ const errorMessages: Record = { "invalid-credentials": "Email or password is incorrect.", - "not-allowed": "That email is not approved for the dashboard.", + "not-allowed": "That account is not approved for the dashboard.", "supabase-env-missing": "Supabase Auth is not configured yet.", }; @@ -47,14 +47,14 @@ export default async function SignInPage({ -

Operator sign-in · restricted access

+

Workspace sign-in · team access

Command Center

-

Sign in with your approved operator account.

+

Sign in with your Ghimtech workspace account.

{error && errorMessages[error] ? ( diff --git a/src/app/sign-up/[[...sign-up]]/page.tsx b/src/app/sign-up/[[...sign-up]]/page.tsx index 7482efa..b684611 100644 --- a/src/app/sign-up/[[...sign-up]]/page.tsx +++ b/src/app/sign-up/[[...sign-up]]/page.tsx @@ -10,7 +10,8 @@ type AuthSearchParams = Promise<{ const errorMessages: Record = { "invalid-signup": "Use a valid email and a password of at least 8 characters.", - "not-allowed": "That email is not approved for the dashboard.", + "invalid-invite": "A valid invite code is required to join the workspace.", + "not-allowed": "That account is not approved for the dashboard.", "signup-failed": "Enrollment failed. Try signing in if the account already exists.", "supabase-env-missing": "Supabase Auth is not configured yet.", }; @@ -42,14 +43,16 @@ export default async function SignUpPage({ -

Operator enrollment

+

Team enrollment

Create Account

-

Create the dashboard operator account.

+

+ Join the Ghimtech workspace with your team invite code. +

{error && errorMessages[error] ? ( @@ -111,6 +114,21 @@ export default async function SignUpPage({ placeholder="8 characters minimum" /> + + + + ))} + {attachError && {attachError}} + + )} + { e.preventDefault(); @@ -141,13 +265,34 @@ export function CopilotChat() { }} className="border-t border-white/[0.06] p-3 flex items-center gap-2" > + void addFiles(e.target.files)} + /> + setInput(e.target.value)} - placeholder="Message your copilot — ask it to do things…" + placeholder="Message your copilot — attach photos or files…" className="flex-1 bg-white/[0.03] border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-signal-400/50" /> - diff --git a/src/components/dashboard/projects/repo-activity-panel.tsx b/src/components/dashboard/projects/repo-activity-panel.tsx new file mode 100644 index 0000000..ade2022 --- /dev/null +++ b/src/components/dashboard/projects/repo-activity-panel.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { GitBranch, GitCommitHorizontal, GitPullRequest, CircleDot, Loader2, Star } from "lucide-react"; +import { relativeTime } from "@/lib/dashboard/format"; + +interface RepoActivity { + repo: string; + url: string; + defaultBranch: string; + pushedAt: string | null; + stars: number; + commits: { sha: string; message: string; author: string | null; date: string | null; url: string }[]; + pullRequests: { number: number; title: string; author: string | null; draft: boolean; updatedAt: string; url: string }[]; + issues: { number: number; title: string; updatedAt: string; url: string }[]; +} + +const REFRESH_MS = 60_000; + +/** + * Live GitHub activity for a repo-linked project. Polls the API (which reads + * GitHub uncached) every minute while the tab is visible, so the tracker + * shows what's actually happening in the repo right now. + */ +export function RepoActivityPanel({ projectId, repo }: { projectId: string; repo: string }) { + const [activity, setActivity] = useState(null); + const [error, setError] = useState(null); + const [fetchedAt, setFetchedAt] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + try { + const res = await fetch(`/api/projects/${projectId}/github`, { cache: "no-store" }); + const json = await res.json().catch(() => null); + if (!res.ok || !json?.ok) { + setError(json?.error?.message ?? "Couldn't reach GitHub."); + } else { + setActivity(json.data.activity); + setFetchedAt(json.data.fetchedAt); + setError(null); + } + } catch { + setError("Couldn't reach GitHub."); + } + setLoading(false); + }, [projectId]); + + useEffect(() => { + void load(); + const timer = setInterval(() => { + if (document.visibilityState === "visible") void load(); + }, REFRESH_MS); + return () => clearInterval(timer); + }, [load]); + + return ( +
+
+ +

Live repo activity

+ + {repo} + + + {loading ? ( + + ) : ( + + )} + {fetchedAt ? `updated ${relativeTime(fetchedAt)}` : "connecting…"} + +
+ + {error ? ( +

{error}

+ ) : !activity ? ( +

Reading {repo}…

+ ) : ( + <> +
+ + last push {activity.pushedAt ? relativeTime(activity.pushedAt) : "—"} · {activity.defaultBranch} + + + {activity.pullRequests.length} open PR + {activity.pullRequests.length === 1 ? "" : "s"} + + + {activity.issues.length} open issue + {activity.issues.length === 1 ? "" : "s"} + + + {activity.stars} + +
+ +
+
+

Recent commits

+ {activity.commits.length === 0 ? ( +

No commits yet.

+ ) : ( +
    + {activity.commits.slice(0, 6).map((c) => ( +
  • + + + {c.message} + + + {c.date ? relativeTime(c.date) : c.sha} + +
  • + ))} +
+ )} +
+ +
+
+

Open pull requests

+ {activity.pullRequests.length === 0 ? ( +

None open.

+ ) : ( + + )} +
+ +
+

Open issues

+ {activity.issues.length === 0 ? ( +

None open.

+ ) : ( +
    + {activity.issues.slice(0, 4).map((i) => ( +
  • + #{i.number} + + {i.title} + + + {relativeTime(i.updatedAt)} + +
  • + ))} +
+ )} +
+
+
+ + )} +
+ ); +} diff --git a/src/lib/copilot-tools.ts b/src/lib/copilot-tools.ts index 1a12ac7..c246179 100644 --- a/src/lib/copilot-tools.ts +++ b/src/lib/copilot-tools.ts @@ -1,6 +1,6 @@ import "server-only"; import { prisma } from "@/lib/prisma"; -import { fetchRepoContext, scanRepoSecrets, openPullRequest } from "@/lib/github"; +import { fetchRepoActivity, fetchRepoContext, scanRepoSecrets, openPullRequest } from "@/lib/github"; /** * Copilot agent tools. `web_search` is OpenAI's built-in browsing tool @@ -79,6 +79,19 @@ export const COPILOT_TOOLS = [ }, strict: false, }, + { + type: "function" as const, + name: "read_repo_activity", + description: + "Live GitHub activity for a project's linked repo by project slug: recent commits, open pull requests, open issues, last push time. Use for 'what's happening / what changed recently' questions.", + parameters: { + type: "object", + properties: { slug: { type: "string" } }, + required: ["slug"], + additionalProperties: false, + }, + strict: false, + }, { type: "function" as const, name: "scan_repo_secrets", @@ -194,6 +207,14 @@ export async function executeTool( recentCommits: ctx.recentCommits, }; } + case "read_repo_activity": { + const slug = s(args.slug); + const p = await prisma.project.findFirst({ where: { slug, userId }, select: { sourceRepo: true } }); + if (!p?.sourceRepo) return { error: "that project has no linked repo" }; + const activity = await fetchRepoActivity(p.sourceRepo); + if (!activity) return { error: "couldn't read the repo" }; + return activity; + } case "scan_repo_secrets": { const slug = s(args.slug); const p = await prisma.project.findFirst({ where: { slug, userId }, select: { sourceRepo: true } }); @@ -242,6 +263,8 @@ export function toolLabel(name: string, result: unknown): string { return r.created ? "saved a note" : "tried to save a note"; case "read_project_repo": return r.repo ? `read ${String(r.repo)}` : "tried to read a repo"; + case "read_repo_activity": + return r.repo ? `checked live activity on ${String(r.repo)}` : "tried to check repo activity"; case "scan_repo_secrets": return r.findingCount !== undefined ? `scanned ${String(r.repo ?? "repo")} — ${String(r.findingCount)} finding(s)` : "scanned a repo"; case "open_pull_request": diff --git a/src/lib/github.ts b/src/lib/github.ts index 2c7eeb9..de20375 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -94,6 +94,88 @@ export async function fetchRepoContext(repo: string): Promise { + const meta = await ghJson<{ + full_name?: string; + html_url?: string; + default_branch?: string; + pushed_at?: string; + stargazers_count?: number; + }>(`/repos/${repo}`); + if (!meta?.full_name) return null; + + const [commitsRaw, pullsRaw, issuesRaw] = await Promise.all([ + ghJson>(`/repos/${repo}/commits?per_page=10`), + ghJson>(`/repos/${repo}/pulls?state=open&per_page=10`), + ghJson>(`/repos/${repo}/issues?state=open&per_page=15`), + ]); + + return { + repo: meta.full_name, + url: meta.html_url ?? `https://github.com/${repo}`, + defaultBranch: meta.default_branch ?? "main", + pushedAt: meta.pushed_at ?? null, + stars: meta.stargazers_count ?? 0, + commits: (commitsRaw ?? []).map((c) => ({ + sha: c.sha.slice(0, 7), + message: c.commit.message.split("\n")[0].slice(0, 160), + author: c.author?.login ?? c.commit.author?.name ?? null, + date: c.commit.author?.date ?? null, + url: c.html_url, + })), + pullRequests: (pullsRaw ?? []).map((p) => ({ + number: p.number, + title: p.title.slice(0, 160), + author: p.user?.login ?? null, + draft: Boolean(p.draft), + updatedAt: p.updated_at, + url: p.html_url, + })), + issues: (issuesRaw ?? []) + .filter((i) => !i.pull_request) + .map((i) => ({ + number: i.number, + title: i.title.slice(0, 160), + updatedAt: i.updated_at, + url: i.html_url, + })), + }; +} + /* ============================================================ Write + scan helpers (used by the agentic Copilot tools) ============================================================ */ From 4f008fe9f5043cec30e576a42b09c81bf7707c99 Mon Sep 17 00:00:00 2001 From: Durga Ghimeray <152849649+durga710@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:28:42 -0400 Subject: [PATCH 03/13] feat: link a GitHub repo to a project from the dashboard (#3) sourceRepo previously had no UI or API path to ever be set, so the live repo activity panel could never appear. The project page now renders the panel unconditionally: unlinked projects get an inline owner/repo form that PATCHes the project, linked ones get a pencil to change it. ProjectCreate/Update schemas accept a validated sourceRepo. https://claude.ai/code/session_013M57ExYGVz7k4pFXNMTBL9 Co-authored-by: Claude --- src/app/dashboard/projects/[slug]/page.tsx | 6 +- .../projects/repo-activity-panel.tsx | 116 +++++++++++++++++- src/lib/validation.ts | 6 + 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/src/app/dashboard/projects/[slug]/page.tsx b/src/app/dashboard/projects/[slug]/page.tsx index 7833b1b..c3999fd 100644 --- a/src/app/dashboard/projects/[slug]/page.tsx +++ b/src/app/dashboard/projects/[slug]/page.tsx @@ -70,11 +70,9 @@ export default async function ProjectDetailPage({ params }: { params: Promise<{ )} + {project.sourceRepo && ( - <> - - - + )}
diff --git a/src/components/dashboard/projects/repo-activity-panel.tsx b/src/components/dashboard/projects/repo-activity-panel.tsx index ade2022..e5b1c97 100644 --- a/src/components/dashboard/projects/repo-activity-panel.tsx +++ b/src/components/dashboard/projects/repo-activity-panel.tsx @@ -1,7 +1,8 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { GitBranch, GitCommitHorizontal, GitPullRequest, CircleDot, Loader2, Star } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { GitBranch, GitCommitHorizontal, GitPullRequest, CircleDot, Loader2, Star, Pencil } from "lucide-react"; import { relativeTime } from "@/lib/dashboard/format"; interface RepoActivity { @@ -18,11 +19,19 @@ interface RepoActivity { const REFRESH_MS = 60_000; /** - * Live GitHub activity for a repo-linked project. Polls the API (which reads - * GitHub uncached) every minute while the tab is visible, so the tracker - * shows what's actually happening in the repo right now. + * Live GitHub activity for a project. Polls the API (which reads GitHub + * uncached) every minute while the tab is visible, so the tracker shows + * what's actually happening in the repo right now. When the project has no + * linked repo yet, renders an inline "link a repo" form instead (PATCHes + * the project's sourceRepo). */ -export function RepoActivityPanel({ projectId, repo }: { projectId: string; repo: string }) { +export function RepoActivityPanel({ projectId, repo: initialRepo }: { projectId: string; repo: string | null }) { + const router = useRouter(); + const [repo, setRepo] = useState(initialRepo); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(initialRepo ?? ""); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); const [activity, setActivity] = useState(null); const [error, setError] = useState(null); const [fetchedAt, setFetchedAt] = useState(null); @@ -46,12 +55,93 @@ export function RepoActivityPanel({ projectId, repo }: { projectId: string; repo }, [projectId]); useEffect(() => { + if (!repo) return; + setLoading(true); void load(); const timer = setInterval(() => { if (document.visibilityState === "visible") void load(); }, REFRESH_MS); return () => clearInterval(timer); - }, [load]); + }, [load, repo]); + + async function saveRepo() { + const value = draft.trim(); + if (!/^[\w.-]+\/[\w.-]+$/.test(value)) { + setSaveError('Use "owner/name", e.g. durga710/rayhealth-evv-platform'); + return; + } + setSaving(true); + setSaveError(null); + try { + const res = await fetch(`/api/projects/${projectId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceRepo: value }), + }); + const json = await res.json().catch(() => null); + if (!res.ok || !json?.ok) { + setSaveError(json?.error?.message ?? "Couldn't save."); + } else { + setRepo(value); + setEditing(false); + setActivity(null); + setError(null); + router.refresh(); + } + } catch { + setSaveError("Couldn't save."); + } + setSaving(false); + } + + const linkForm = ( +
{ + e.preventDefault(); + void saveRepo(); + }} + className="flex flex-wrap items-center gap-2" + > + setDraft(e.target.value)} + placeholder="owner/repo — e.g. durga710/rayhealth-evv-platform" + className="flex-1 min-w-[16rem] bg-white/[0.03] border border-white/10 rounded-lg px-3 py-2 font-mono text-xs text-white placeholder:text-zinc-600 focus:outline-none focus:border-signal-400/50" + /> + + {repo && ( + + )} + {saveError && {saveError}} +
+ ); + + if (!repo) { + return ( +
+
+ +

Live repo activity

+
+

+ Link this project to a GitHub repo to track commits, pull requests, and issues live. +

+ {linkForm} +
+ ); + } return (
@@ -66,6 +156,18 @@ export function RepoActivityPanel({ projectId, repo }: { projectId: string; repo > {repo} + {loading ? ( @@ -76,6 +178,8 @@ export function RepoActivityPanel({ projectId, repo }: { projectId: string; repo
+ {editing &&
{linkForm}
} + {error ? (

{error}

) : !activity ? ( diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 7a5d7f9..ebb5d23 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -58,6 +58,12 @@ export const ProjectCreateSchema = z.object({ .string() .regex(/^#[0-9a-fA-F]{6}$/, "Color must be a 6-digit hex code") .optional(), + sourceRepo: z + .string() + .max(140) + .regex(/^[\w.-]+\/[\w.-]+$/, 'Repo must be in "owner/name" form, e.g. durga710/rayhealth-evv-platform') + .nullable() + .optional(), }); export const ProjectUpdateSchema = ProjectCreateSchema.partial().omit({ slug: true }); From bf0c82691553afff9750b02b376be62fbe050f9a Mon Sep 17 00:00:00 2001 From: Durga Ghimeray <152849649+durga710@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:44:53 -0400 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20Replit-style=20GhimTech=20?= =?UTF-8?q?=E2=80=94=20AI=20app=20builder,=20code=20editor,=20deploy=20sta?= =?UTF-8?q?tus=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: copilot builds apps — multi-file pushes to GitHub with PR + preview The Copilot can now build a described app end-to-end: a new build_app_files tool writes up to 15 files to a branch of one of the operator's repos in a single commit (git data API: tree + commit + ref) and optionally opens a PR. Repos connected to Vercel get an automatic live preview deployment on that PR, closing the describe → build → running-app loop. - github.ts: pushFilesToRepo() (blob/tree/commit/ref) and createPullRequest(); openPullRequest reuses the latter - repo-files.ts: pure guardrails (repo/branch/path validation, file count + size caps) with unit tests - copilot system prompt teaches the build workflow; chat starter added https://claude.ai/code/session_013M57ExYGVz7k4pFXNMTBL9 * feat: in-browser code editor + live deployment status Replit-style workspace at /dashboard/code: open any repo the token can reach, browse its file tree, edit in Monaco (vs-dark, language by extension), and commit all edits as one commit to the branch. New files supported; HTML files get a sandboxed iframe 'run preview'. Repo picker pre-filled from projects' linked repos, deep-linkable via ?repo=. - /api/github/tree, /api/github/file (reads), /api/github/commit (write, audited, reuses pushFilesToRepo + repo-files guardrails) - live repo panel: deployments (environment, state, open link) read from GitHub's deployments API, plus an 'edit code' link - sidebar gains a Code entry https://claude.ai/code/session_013M57ExYGVz7k4pFXNMTBL9 --------- Co-authored-by: Claude --- package-lock.json | 98 +++++ package.json | 1 + src/app/api/copilot/chat/route.ts | 1 + src/app/api/github/commit/route.ts | 69 ++++ src/app/api/github/file/route.ts | 34 ++ src/app/api/github/tree/route.ts | 32 ++ src/app/dashboard/code/page.tsx | 45 +++ .../dashboard/code/code-workspace.tsx | 337 ++++++++++++++++++ .../dashboard/copilot/copilot-chat.tsx | 2 +- .../dashboard/dashboard-sidebar.tsx | 2 + .../projects/repo-activity-panel.tsx | 50 ++- src/lib/copilot-tools.ts | 86 ++++- src/lib/github.ts | 157 +++++++- src/lib/repo-files.ts | 64 ++++ tests/repo-files.test.ts | 83 +++++ 15 files changed, 1048 insertions(+), 13 deletions(-) create mode 100644 src/app/api/github/commit/route.ts create mode 100644 src/app/api/github/file/route.ts create mode 100644 src/app/api/github/tree/route.ts create mode 100644 src/app/dashboard/code/page.tsx create mode 100644 src/components/dashboard/code/code-workspace.tsx create mode 100644 src/lib/repo-files.ts create mode 100644 tests/repo-files.test.ts diff --git a/package-lock.json b/package-lock.json index 03d8cfb..16c03d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^3.9.0", + "@monaco-editor/react": "^4.7.0", "@prisma/client": "^5.20.0", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", @@ -116,6 +117,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -132,6 +134,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -148,6 +151,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -164,6 +168,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -180,6 +185,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -196,6 +202,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -212,6 +219,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -228,6 +236,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -244,6 +253,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -260,6 +270,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -276,6 +287,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -292,6 +304,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -308,6 +321,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -324,6 +338,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -340,6 +355,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -356,6 +372,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -372,6 +389,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -388,6 +406,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -404,6 +423,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -420,6 +440,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -436,6 +457,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -452,6 +474,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -468,6 +491,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -484,6 +508,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -500,6 +525,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -516,6 +542,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1283,6 +1310,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2815,6 +2865,14 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.4", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", @@ -4422,6 +4480,16 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6296,6 +6364,19 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6351,6 +6432,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/motion-dom": { "version": "11.18.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", @@ -7720,6 +7812,12 @@ "dev": true, "license": "MIT" }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", diff --git a/package.json b/package.json index fd3392f..8120b12 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@hookform/resolvers": "^3.9.0", + "@monaco-editor/react": "^4.7.0", "@prisma/client": "^5.20.0", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", diff --git a/src/app/api/copilot/chat/route.ts b/src/app/api/copilot/chat/route.ts index 8eb238e..468425c 100644 --- a/src/app/api/copilot/chat/route.ts +++ b/src/app/api/copilot/chat/route.ts @@ -103,6 +103,7 @@ export async function POST(req: Request) { "You are the founder's operations copilot inside their dashboard. Be direct, concrete, and genuinely useful. " + "You can BROWSE THE WEB (web_search) and CALL TOOLS that act on the operator's real data (list/create tasks, update task status, list projects, create notes, read a project's linked GitHub repo and its live activity). " + "Use tools when they help — actually create/update things when asked, search the web for current info, read the repo when asked about a project's direction. " + + "You can BUILD APPS: when the operator describes an app or feature, write the complete files with build_app_files into one of their repos (pick a sensible stack — a static index.html for tiny things, Vite/Next for real apps), open a PR, and give them the PR link; Vercel-connected repos get a live preview deployment on the PR automatically. Plan the file set first, push in as few calls as possible, and reuse the same branch for follow-up fixes. " + "The operator may attach photos or files (screenshots, documents, code) — look at them carefully and use what they contain. After acting, confirm what you did in one line. Reference projects/tasks by name. Keep replies tight unless asked to expand.\n\n" + "--- LIVE CONTEXT ---\n" + `Operator: ${user.firstName ?? "the founder"}.\n` + diff --git a/src/app/api/github/commit/route.ts b/src/app/api/github/commit/route.ts new file mode 100644 index 0000000..14cc1c1 --- /dev/null +++ b/src/app/api/github/commit/route.ts @@ -0,0 +1,69 @@ +/** + * /api/github/commit + * POST → commit edited files to a branch from the in-browser code editor. + * Body: { repo, branch, message, files: [{path, content}] }. + * + * Operator-only, rate-limited. Uses the same guardrails and single-commit + * push plumbing as the Copilot's app builder. + */ + +import { z } from "zod"; +import { requireUser } from "@/lib/auth"; +import { audit } from "@/lib/audit"; +import { ok, apiErrors } from "@/lib/api-response"; +import { rateLimit } from "@/lib/rate-limit"; +import { pushFilesToRepo } from "@/lib/github"; +import { + isValidBranchName, + isValidRepoName, + validatePushFiles, + MAX_FILE_CHARS, + MAX_PUSH_FILES, +} from "@/lib/repo-files"; + +export const runtime = "nodejs"; + +const CommitSchema = z.object({ + repo: z.string().max(140), + branch: z.string().max(80), + message: z.string().min(1).max(200), + files: z + .array(z.object({ path: z.string().max(200), content: z.string().max(MAX_FILE_CHARS) })) + .min(1) + .max(MAX_PUSH_FILES), +}); + +export async function POST(req: Request) { + const user = await requireUser(); + + const rl = rateLimit(`code.commit:${user.id}`, { limit: 60, windowMs: 60 * 60 * 1000 }); + if (!rl.success) return apiErrors.rateLimit(rl.reset); + + let body: unknown; + try { + body = await req.json(); + } catch { + return apiErrors.badRequest("Request body must be valid JSON"); + } + const parsed = CommitSchema.safeParse(body); + if (!parsed.success) return apiErrors.validation(parsed.error); + + const { repo, branch, message, files } = parsed.data; + if (!isValidRepoName(repo)) return apiErrors.badRequest('repo must be "owner/name"'); + if (!isValidBranchName(branch)) return apiErrors.badRequest("invalid branch name"); + const check = validatePushFiles(files); + if (!check.ok) return apiErrors.badRequest(check.error); + + const result = await pushFilesToRepo(repo, { branch, message, files }); + if ("error" in result) return apiErrors.badRequest(result.error); + + await audit({ + action: "code.commit", + target: `${repo}#${branch}`, + actorId: user.id, + diff: { message, paths: files.map((f) => f.path) }, + req, + }); + + return ok(result); +} diff --git a/src/app/api/github/file/route.ts b/src/app/api/github/file/route.ts new file mode 100644 index 0000000..a2d6ca2 --- /dev/null +++ b/src/app/api/github/file/route.ts @@ -0,0 +1,34 @@ +/** + * /api/github/file?repo=owner/name&path=src/x.ts&ref=branch + * GET → one file's text content for the in-browser code editor. + * + * Operator-only, rate-limited. + */ + +import { requireUser } from "@/lib/auth"; +import { ok, apiErrors } from "@/lib/api-response"; +import { rateLimit } from "@/lib/rate-limit"; +import { fetchRepoFileContent } from "@/lib/github"; +import { isSafeRepoPath, isValidRepoName } from "@/lib/repo-files"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const user = await requireUser(); + + const rl = rateLimit(`code.read:${user.id}`, { limit: 600, windowMs: 60 * 60 * 1000 }); + if (!rl.success) return apiErrors.rateLimit(rl.reset); + + const url = new URL(req.url); + const repo = url.searchParams.get("repo") ?? ""; + const path = url.searchParams.get("path") ?? ""; + const ref = url.searchParams.get("ref") ?? undefined; + if (!isValidRepoName(repo)) return apiErrors.badRequest('repo must be "owner/name"'); + if (!isSafeRepoPath(path)) return apiErrors.badRequest("invalid file path"); + + const file = await fetchRepoFileContent(repo, path, ref); + if (!file) return apiErrors.badRequest("Couldn't read that file (missing, binary, or too large)."); + + return ok({ path, content: file.content }); +} diff --git a/src/app/api/github/tree/route.ts b/src/app/api/github/tree/route.ts new file mode 100644 index 0000000..be61181 --- /dev/null +++ b/src/app/api/github/tree/route.ts @@ -0,0 +1,32 @@ +/** + * /api/github/tree?repo=owner/name&ref=branch + * GET → editable file list for the in-browser code editor. + * + * Operator-only, rate-limited. The repo must be reachable by GITHUB_TOKEN. + */ + +import { requireUser } from "@/lib/auth"; +import { ok, apiErrors } from "@/lib/api-response"; +import { rateLimit } from "@/lib/rate-limit"; +import { fetchRepoTree } from "@/lib/github"; +import { isValidRepoName } from "@/lib/repo-files"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const user = await requireUser(); + + const rl = rateLimit(`code.read:${user.id}`, { limit: 600, windowMs: 60 * 60 * 1000 }); + if (!rl.success) return apiErrors.rateLimit(rl.reset); + + const url = new URL(req.url); + const repo = url.searchParams.get("repo") ?? ""; + const ref = url.searchParams.get("ref") ?? undefined; + if (!isValidRepoName(repo)) return apiErrors.badRequest('repo must be "owner/name"'); + + const tree = await fetchRepoTree(repo, ref); + if (!tree) return apiErrors.badRequest(`Couldn't read ${repo} — check the token's repo access.`); + + return ok(tree); +} diff --git a/src/app/dashboard/code/page.tsx b/src/app/dashboard/code/page.tsx new file mode 100644 index 0000000..7e92815 --- /dev/null +++ b/src/app/dashboard/code/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import { requireUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { CodeWorkspace } from "@/components/dashboard/code/code-workspace"; + +export const metadata: Metadata = { + title: "Code", + robots: { index: false, follow: false }, +}; + +/** + * In-browser code editor: open any repo the GITHUB_TOKEN can reach, edit + * files in Monaco, and commit to a branch — Replit-style, GitHub-backed. + */ +export default async function CodePage({ + searchParams, +}: { + searchParams: Promise<{ repo?: string | string[] }>; +}) { + const user = await requireUser(); + const params = await searchParams; + const requested = Array.isArray(params.repo) ? params.repo[0] : params.repo; + + const projects = await prisma.project.findMany({ + where: { userId: user.id, sourceRepo: { not: null } }, + select: { sourceRepo: true }, + orderBy: { updatedAt: "desc" }, + take: 20, + }); + const linkedRepos = Array.from( + new Set(projects.map((p) => p.sourceRepo).filter((r): r is string => Boolean(r))), + ); + + return ( +
+
+

Code

+

+ Edit your repos right here — changes commit straight to GitHub. +

+
+ +
+ ); +} diff --git a/src/components/dashboard/code/code-workspace.tsx b/src/components/dashboard/code/code-workspace.tsx new file mode 100644 index 0000000..95c7b9b --- /dev/null +++ b/src/components/dashboard/code/code-workspace.tsx @@ -0,0 +1,337 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import { FileCode2, FilePlus2, FolderGit2, GitCommitHorizontal, Loader2, Play, X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Monaco = dynamic(() => import("@monaco-editor/react"), { + ssr: false, + loading: () => ( +
+ + loading editor… + +
+ ), +}); + +interface TreeEntry { + path: string; + size: number; +} + +const LANGUAGES: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + json: "json", + css: "css", + scss: "scss", + html: "html", + md: "markdown", + py: "python", + rb: "ruby", + go: "go", + rs: "rust", + java: "java", + sql: "sql", + sh: "shell", + yml: "yaml", + yaml: "yaml", + toml: "ini", + prisma: "graphql", +}; + +function languageFor(path: string): string { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + return LANGUAGES[ext] ?? "plaintext"; +} + +/** + * GitHub-backed code workspace: file tree + Monaco editor + commit bar. + * Edits accumulate locally (dirty map) and ship as ONE commit to the chosen + * branch via /api/github/commit. HTML files get a sandboxed live preview — + * the "run" button for static apps. + */ +export function CodeWorkspace({ initialRepo, repoOptions }: { initialRepo: string; repoOptions: string[] }) { + const [repoInput, setRepoInput] = useState(initialRepo); + const [repo, setRepo] = useState(initialRepo); + const [branch, setBranch] = useState(""); + const [files, setFiles] = useState([]); + const [treeError, setTreeError] = useState(null); + const [loadingTree, setLoadingTree] = useState(false); + + const [selected, setSelected] = useState(null); + const [contents, setContents] = useState>({}); + const [dirty, setDirty] = useState>({}); + const [loadingFile, setLoadingFile] = useState(false); + + const [message, setMessage] = useState(""); + const [committing, setCommitting] = useState(false); + const [commitNote, setCommitNote] = useState(null); + const [preview, setPreview] = useState(false); + + const dirtyCount = Object.keys(dirty).length; + const currentContent = selected ? (dirty[selected] ?? contents[selected] ?? "") : ""; + + const loadTree = useCallback(async (target: string) => { + if (!target) return; + setLoadingTree(true); + setTreeError(null); + setFiles([]); + setSelected(null); + setContents({}); + setDirty({}); + setCommitNote(null); + try { + const res = await fetch(`/api/github/tree?repo=${encodeURIComponent(target)}`, { cache: "no-store" }); + const json = await res.json().catch(() => null); + if (!res.ok || !json?.ok) { + setTreeError(json?.error?.message ?? "Couldn't load the repo."); + } else { + setFiles(json.data.files); + setBranch(json.data.branch); + } + } catch { + setTreeError("Couldn't load the repo."); + } + setLoadingTree(false); + }, []); + + useEffect(() => { + if (initialRepo) void loadTree(initialRepo); + }, [initialRepo, loadTree]); + + async function openFile(path: string) { + setSelected(path); + setPreview(false); + if (dirty[path] !== undefined || contents[path] !== undefined) return; + setLoadingFile(true); + try { + const res = await fetch( + `/api/github/file?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(path)}&ref=${encodeURIComponent(branch)}`, + { cache: "no-store" }, + ); + const json = await res.json().catch(() => null); + setContents((c) => ({ + ...c, + [path]: res.ok && json?.ok ? json.data.content : `// ${json?.error?.message ?? "couldn't load file"}`, + })); + } catch { + setContents((c) => ({ ...c, [path]: "// couldn't load file" })); + } + setLoadingFile(false); + } + + function newFile() { + const path = window.prompt('New file path (e.g. "src/app.ts")')?.trim(); + if (!path) return; + setDirty((d) => ({ ...d, [path]: d[path] ?? "// new file\n" })); + setFiles((f) => (f.some((x) => x.path === path) ? f : [{ path, size: 0 }, ...f])); + setSelected(path); + setPreview(false); + } + + async function commit() { + if (!dirtyCount || committing) return; + setCommitting(true); + setCommitNote(null); + try { + const res = await fetch("/api/github/commit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repo, + branch, + message: message.trim() || `Edit ${Object.keys(dirty).join(", ").slice(0, 150)}`, + files: Object.entries(dirty).map(([path, content]) => ({ path, content })), + }), + }); + const json = await res.json().catch(() => null); + if (!res.ok || !json?.ok) { + setCommitNote(json?.error?.message ?? "Commit failed."); + } else { + setContents((c) => ({ ...c, ...dirty })); + setDirty({}); + setMessage(""); + setCommitNote(`Committed to ${branch} ✓`); + } + } catch { + setCommitNote("Commit failed."); + } + setCommitting(false); + } + + const isHtml = useMemo(() => (selected ?? "").toLowerCase().endsWith(".html"), [selected]); + + return ( +
+ {/* Top bar: repo + branch + commit */} +
+ +
{ + e.preventDefault(); + const target = repoInput.trim(); + if (target) { + setRepo(target); + void loadTree(target); + } + }} + className="flex items-center gap-2" + > + setRepoInput(e.target.value)} + list="repo-options" + placeholder="owner/repo" + className="w-64 bg-white/[0.03] border border-white/10 rounded-lg px-3 py-1.5 font-mono text-xs text-white placeholder:text-zinc-600 focus:outline-none focus:border-signal-400/50" + /> + + {repoOptions.map((r) => ( + + +
+ {branch && ( + + on {branch} + + )} + +
+ {commitNote && {commitNote}} + setMessage(e.target.value)} + placeholder={dirtyCount ? "Commit message" : "No changes yet"} + disabled={!dirtyCount} + className="w-56 bg-white/[0.03] border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white placeholder:text-zinc-600 focus:outline-none focus:border-signal-400/50 disabled:opacity-50" + /> + +
+
+ +
+ {/* File tree */} + + + {/* Editor / preview */} +
+ {selected && ( +
+ + {selected} + {isHtml && ( + + )} +
+ )} +
+ {!selected ? ( +
+ Select a file to start editing. +
+ ) : loadingFile ? ( +
+ + loading file… + +
+ ) : preview && isHtml ? ( +