SPB Git

Repository Public

page.tsx 14.1 KB 254 lines tsx

1'use client';2import { useEffect, useState } from 'react';3import { useRouter } from 'next/navigation';4import { useQuery, useQueryClient } from '@tanstack/react-query';5import type { CreateProject, Project } from '@cr/shared';6import { Ellipsis, FolderTree, GitBranch, Pin, Plus, SquareTerminal } from 'lucide-react';7import { api, asList, errorMessage } from '@/lib/api';8import { qk, useProjects, useSaveSettings, useSettings } from '@/lib/queries';9import { useUi } from '@/stores/ui';10import { useNow } from '@/hooks/use-viewport';11import { fmtRelative } from '@/lib/format';12import { toast } from '@/stores/toasts';13import { Button } from '@/components/ui/button';14import { Badge } from '@/components/ui/badge';15import { ConfirmDialog, Dialog } from '@/components/ui/dialog';16import { Field, Input, Textarea } from '@/components/ui/input';17import { Select } from '@/components/ui/select';18import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown';19import { EmptyState, ErrorState, PageHeader, Skeleton } from '@/components/ui/misc';20import { cn } from '@/lib/cn';21import { PullToRefresh } from '@/components/ui/pull-to-refresh';22import { IllusFolder } from '@/components/ui/illustrations';23import { useBrand } from '@/stores/branding';2425const parseTags = (s: string) => [...new Set(s.split(',').map((t) => t.trim()).filter(Boolean))].slice(0, 20);2627export default function ProjectsPage() {28  const brand = useBrand();29  const q = useProjects();30  const now = useNow(15_000);31  const [creating, setCreating] = useState(false);32  const [editing, setEditing] = useState<Project | null>(null);33  const [removing, setRemoving] = useState<Project | null>(null);34  const qc = useQueryClient();35  const list = [...(q.data ?? [])].sort((a, b) => Number(b.pinned) - Number(a.pinned) || a.name.localeCompare(b.name));36  return (37    <div className="flex h-full flex-col overflow-hidden">38      <PageHeader title="Projects" description="A project is a directory inside a workspace root on the server." actions={<Button variant="primary" onClick={() => setCreating(true)} data-testid="new-project"><Plus className="size-3.5" /> New project</Button>} />39      <PullToRefresh onRefresh={() => q.refetch()} className="min-h-0 flex-1 p-4 pb-10 md:p-6">40        {q.isPending ? (41          <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">{[0, 1, 2].map((i) => <Skeleton key={i} className="h-40" />)}</div>42        ) : q.isError ? (43          <ErrorState error={q.error} onRetry={() => q.refetch()} />44        ) : list.length === 0 ? (45          <EmptyState illustration={<IllusFolder />} title="No projects yet" description="Register an existing directory, create a new one, or clone a Git repository." action={<Button variant="primary" onClick={() => setCreating(true)}>New project</Button>} />46        ) : (47          <ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3" data-testid="project-list">48            {list.map((p) => <ProjectCard key={p.id} project={p} now={now} onEdit={() => setEditing(p)} onRemove={() => setRemoving(p)} />)}49          </ul>50        )}51      </PullToRefresh>52      <CreateProjectDialog open={creating} onClose={() => setCreating(false)} />53      <EditProjectDialog project={editing} onClose={() => setEditing(null)} />54      <ConfirmDialog55        open={!!removing}56        onOpenChange={(o) => !o && setRemoving(null)}57        title="Unregister project?"58        description={<>“{removing?.name}” will be removed from {brand.product}. <strong className="text-text">Files are not deleted</strong>: the directory <span className="font-mono">{removing?.rootLabel}/{removing?.path}</span> stays on the server and can be registered again.</>}59        confirmLabel="Unregister"60        danger61        onConfirm={async () => {62          if (!removing) return;63          try {64            await api.delete(`/projects/${removing.id}`);65            await qc.invalidateQueries({ queryKey: qk.projects });66          } catch (e) {67            toast.error('Could not unregister', errorMessage(e));68          }69        }}70      />71    </div>72  );73}7475function ProjectCard({ project: p, now, onEdit, onRemove }: { project: Project; now: number; onEdit: () => void; onRemove: () => void }) {76  const router = useRouter();77  const qc = useQueryClient();78  const openNew = useUi((s) => s.openNewSession);79  const settings = useSettings();80  const save = useSaveSettings();81  const togglePin = async () => {82    try {83      await api.patch(`/projects/${p.id}`, { pinned: !p.pinned });84      await qc.invalidateQueries({ queryKey: qk.projects });85    } catch (e) {86      toast.error('Could not update project', errorMessage(e));87    }88  };89  return (90    <li className="card flex flex-col" data-testid="project-card">91      <div className="flex items-start gap-2 px-4 pt-3">92        <div className="min-w-0 flex-1">93          <h2 className="flex items-center gap-1.5 text-sm font-semibold">94            <span className="truncate">{p.name}</span>95            {p.pinned && <Pin className="size-3 shrink-0 text-muted" aria-label="Pinned" />}96            {settings.defaultProjectId === p.id && <Badge tone="accent">default</Badge>}97          </h2>98          <p className="mt-0.5 truncate font-mono text-[11px] text-muted" title={`${p.rootLabel}/${p.path}`}>{p.rootLabel}/{p.path}</p>99        </div>100        <DropdownMenu>101          <DropdownMenuTrigger asChild>102            <button aria-label={`Actions for ${p.name}`} className="-mr-1.5 inline-flex size-7 items-center justify-center rounded-sm text-muted hover:bg-surface-2 hover:text-text pointer-coarse:size-11"><Ellipsis className="size-4" /></button>103          </DropdownMenuTrigger>104          <DropdownMenuContent>105            <DropdownMenuItem onSelect={onEdit}>Project settings</DropdownMenuItem>106            <DropdownMenuItem onSelect={togglePin}>{p.pinned ? 'Unpin' : 'Pin'}</DropdownMenuItem>107            <DropdownMenuItem onSelect={() => save({ defaultProjectId: settings.defaultProjectId === p.id ? null : p.id }).catch((e) => toast.error('Could not save', errorMessage(e)))}>108              {settings.defaultProjectId === p.id ? 'Clear default project' : 'Set as default project'}109            </DropdownMenuItem>110            <DropdownMenuSeparator />111            <DropdownMenuItem danger onSelect={onRemove}>Unregister…</DropdownMenuItem>112          </DropdownMenuContent>113        </DropdownMenu>114      </div>115      <p className={cn('line-clamp-2 min-h-8 px-4 pt-2 text-xs', p.description ? 'text-text-2' : 'text-muted')}>{p.description || 'No description'}</p>116      <div className="flex flex-wrap items-center gap-1.5 px-4 pt-2.5">117        {p.gitBranch ? (118          <Badge tone={p.gitDirty ? 'warning' : 'neutral'} title={p.gitDirty ? 'Uncommitted changes' : p.gitDirty === false ? 'Clean working tree' : 'Git state unknown'}>119            <GitBranch className="size-3" /> <span className="font-mono">{p.gitBranch}</span>{p.gitDirty && ' · dirty'}120          </Badge>121        ) : (122          <Badge tone="muted">no git</Badge>123        )}124        {p.tags.map((t) => <Badge key={t} tone="muted">{t}</Badge>)}125      </div>126      <p className="tnum px-4 pt-2.5 text-[11px] text-muted">{p.activeSessions} active {p.activeSessions === 1 ? 'session' : 'sessions'} · last activity {fmtRelative(p.lastActivityAt, now)}</p>127      <div className="mt-3 flex gap-2 border-t border-line px-4 py-2.5">128        <Button size="sm" variant="primary" onClick={() => openNew(p.id)}><SquareTerminal className="size-3.5" /> Open terminal</Button>129        <Button size="sm" onClick={() => router.push(`/files/?p=${p.id}`)}><FolderTree className="size-3.5" /> Open files</Button>130      </div>131    </li>132  );133}134135function CreateProjectDialog({ open, onClose }: { open: boolean; onClose: () => void }) {136  const qc = useQueryClient();137  const roots = useQuery({ queryKey: ['roots'], queryFn: async () => asList<{ label: string }>(await api.get('/workspace/roots')), enabled: open });138  const [mode, setMode] = useState<CreateProject['mode']>('create');139  const [form, setForm] = useState({ name: '', description: '', path: '', gitUrl: '', tags: '', rootLabel: '' });140  const [busy, setBusy] = useState(false);141  const [error, setError] = useState<string | null>(null);142  useEffect(() => {143    if (open) {144      setForm({ name: '', description: '', path: '', gitUrl: '', tags: '', rootLabel: '' });145      setMode('create');146      setError(null);147    }148  }, [open]);149  const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setForm((f) => ({ ...f, [k]: e.target.value }));150  const gitOk = /^(https:\/\/|ssh:\/\/|git@[\w.-]+:|gitsrv:)[^\s]+$/.test(form.gitUrl.trim()) && !form.gitUrl.trim().startsWith('-');151  const pathOk = !form.path || (!form.path.startsWith('/') && !form.path.split('/').includes('..'));152  const valid = form.name.trim().length > 0 && pathOk && (mode !== 'clone' || gitOk) && (mode !== 'existing' || form.path.trim().length > 0);153  const submit = async () => {154    setBusy(true);155    setError(null);156    try {157      const body: Partial<CreateProject> = { name: form.name.trim(), description: form.description.trim(), mode, tags: parseTags(form.tags) };158      if (form.path.trim()) body.path = form.path.trim();159      if (form.rootLabel) body.rootLabel = form.rootLabel;160      if (mode === 'clone') body.gitUrl = form.gitUrl.trim();161      await api.post('/projects', body);162      await qc.invalidateQueries({ queryKey: qk.projects });163      onClose();164    } catch (e) {165      setError(errorMessage(e));166    } finally {167      setBusy(false);168    }169  };170  const rootOptions = (roots.data ?? []).map((r) => ({ value: r.label, label: r.label }));171  return (172    <Dialog173      open={open}174      onOpenChange={(o) => !o && onClose()}175      title="New project"176      size="lg"177      footer={178        <>179          {error && <p role="alert" className="mr-auto text-xs text-error" data-testid="project-error">{error}</p>}180          <Button variant="ghost" onClick={onClose}>Cancel</Button>181          <Button variant="primary" loading={busy} disabled={!valid} onClick={submit} data-testid="create-project">{mode === 'clone' ? 'Clone and register' : mode === 'existing' ? 'Register' : 'Create'}</Button>182        </>183      }184    >185      <div className="flex flex-col gap-4">186        <div role="radiogroup" aria-label="How to add the project" className="grid grid-cols-3 gap-1 rounded-md bg-bg p-1">187          {([['create', 'New directory'], ['existing', 'Existing directory'], ['clone', 'Clone Git URL']] as const).map(([m, label]) => (188            <button key={m} role="radio" aria-checked={mode === m} onClick={() => setMode(m)} className={cn('min-h-9 rounded-sm px-2 text-xs', mode === m ? 'bg-surface-2 font-medium text-text' : 'text-muted hover:text-text-2')}>{label}</button>189          ))}190        </div>191        <Field label="Name"><Input value={form.name} onChange={set('name')} maxLength={80} data-testid="project-name" /></Field>192        {mode === 'clone' && (193          <Field label="Git URL" hint="https://, ssh://, git@host:path or gitsrv: URLs." error={form.gitUrl && !gitOk ? 'Unsupported URL.' : null}>194            <Input value={form.gitUrl} onChange={set('gitUrl')} maxLength={500} spellCheck={false} autoCapitalize="off" className="font-mono" />195          </Field>196        )}197        {rootOptions.length > 1 && (198          <Field label="Workspace root"><Select value={form.rootLabel || rootOptions[0]!.value} onValueChange={(v) => setForm((f) => ({ ...f, rootLabel: v }))} options={rootOptions} /></Field>199        )}200        <Field label={mode === 'existing' ? 'Directory (relative to the workspace root)' : 'Directory (optional)'} hint={mode === 'existing' ? 'The directory must already exist.' : 'Defaults to a slug of the name.'} error={!pathOk ? 'Use a relative path without “..”.' : null}>201          <Input value={form.path} onChange={set('path')} maxLength={400} spellCheck={false} autoCapitalize="off" className="font-mono" data-testid="project-path" />202        </Field>203        <Field label="Description"><Textarea value={form.description} onChange={set('description')} maxLength={2000} rows={2} /></Field>204        <Field label="Tags" hint="Comma-separated."><Input value={form.tags} onChange={set('tags')} /></Field>205      </div>206    </Dialog>207  );208}209210function EditProjectDialog({ project, onClose }: { project: Project | null; onClose: () => void }) {211  const qc = useQueryClient();212  const [form, setForm] = useState({ name: '', description: '', tags: '' });213  const [busy, setBusy] = useState(false);214  const [error, setError] = useState<string | null>(null);215  useEffect(() => {216    if (project) setForm({ name: project.name, description: project.description, tags: project.tags.join(', ') });217    setError(null);218  }, [project]);219  const submit = async () => {220    if (!project) return;221    setBusy(true);222    try {223      await api.patch(`/projects/${project.id}`, { name: form.name.trim(), description: form.description.trim(), tags: parseTags(form.tags) });224      await qc.invalidateQueries({ queryKey: qk.projects });225      onClose();226    } catch (e) {227      setError(errorMessage(e));228    } finally {229      setBusy(false);230    }231  };232  return (233    <Dialog234      open={!!project}235      onOpenChange={(o) => !o && onClose()}236      title="Project settings"237      description={project ? <span className="font-mono">{project.rootLabel}/{project.path}{project.gitRemote ? ` · ${project.gitRemote}` : ''}</span> : undefined}238      footer={239        <>240          {error && <p role="alert" className="mr-auto text-xs text-error">{error}</p>}241          <Button variant="ghost" onClick={onClose}>Cancel</Button>242          <Button variant="primary" loading={busy} disabled={!form.name.trim()} onClick={submit}>Save</Button>243        </>244      }245    >246      <div className="flex flex-col gap-4">247        <Field label="Name"><Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} maxLength={80} /></Field>248        <Field label="Description"><Textarea value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} maxLength={2000} rows={3} /></Field>249        <Field label="Tags" hint="Comma-separated."><Input value={form.tags} onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))} /></Field>250      </div>251    </Dialog>252  );253}254
Files main
Menu

Theme