Repository Public
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