diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 74d01da..bc30e48 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -9,6 +9,10 @@ const ConfigSchema = z.object({ PROJECT_ROOT_WHITELIST: z.string().default('/opt'), DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'), LOG_LEVEL: z.string().default('info'), + GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'), + GITEA_USER: z.string().default('indifferentketchup'), + GITEA_TOKEN: z.string().optional(), + GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'), }); export type Config = z.infer; diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index 32c22b2..703bc8b 100644 --- a/apps/server/src/routes/projects.ts +++ b/apps/server/src/routes/projects.ts @@ -9,12 +9,29 @@ import type { Project, AvailableProject } from '../types/api.js'; import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js'; import { listDir, viewFile } from '../services/file_ops.js'; import { getProjectFiles } from '../services/file_index.js'; +import { + bootstrapProject, + BootstrapNameError, + BootstrapCollisionError, + BootstrapPathError, +} from '../services/project_bootstrap.js'; const AddProjectBody = z.object({ path: z.string().min(1), name: z.string().min(1).optional(), }); +const PatchProjectBody = z.object({ + name: z.string().min(1).max(200), +}); + +const CreateProjectBody = z.object({ + name: z.string().min(1).max(64), + commit_message: z.string().min(1).max(200).optional(), + visibility: z.enum(['private', 'public']).optional(), + create_gitea_remote: z.boolean().optional(), +}); + async function isDir(path: string): Promise { try { const s = await stat(path); @@ -49,15 +66,83 @@ export function registerProjectRoutes( config: Config, broker: Broker ): void { - app.get('/api/projects', async () => { + app.get<{ Querystring: { status?: string } }>('/api/projects', async (req) => { + const status = req.query.status === 'archived' ? 'archived' : 'open'; const rows = await sql` - SELECT id, name, path, added_at, last_session_id + SELECT id, name, path, added_at, last_session_id, status, gitea_remote FROM projects + WHERE status = ${status} ORDER BY added_at DESC `; return rows; }); + app.post('/api/projects/create', async (req, reply) => { + const parsed = CreateProjectBody.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: 'invalid body', details: parsed.error.flatten() }; + } + const visibility = parsed.data.visibility ?? 'private'; + const createRemote = parsed.data.create_gitea_remote ?? true; + const commitMessage = parsed.data.commit_message ?? 'Initial commit'; + + let bootstrap; + try { + bootstrap = await bootstrapProject(config, app.log, { + name: parsed.data.name, + commitMessage, + visibility, + createGiteaRemote: createRemote, + }); + } catch (err) { + if (err instanceof BootstrapNameError) { + reply.code(400); + return { error: `invalid project name: ${err.message}` }; + } + if (err instanceof BootstrapCollisionError) { + reply.code(409); + return { error: err.message }; + } + if (err instanceof BootstrapPathError) { + reply.code(400); + return { error: err.message }; + } + app.log.error({ err }, 'bootstrap failed'); + reply.code(500); + return { error: err instanceof Error ? err.message : 'bootstrap failed' }; + } + + // Insert into projects table only after bootstrap succeeded. + try { + const [row] = await sql` + INSERT INTO projects (name, path, gitea_remote) + VALUES (${parsed.data.name}, ${bootstrap.folder_real_path}, ${bootstrap.gitea_remote_url}) + RETURNING id, name, path, added_at, last_session_id, status, gitea_remote + `; + broker.publishUser('default', { type: 'project_created', project: row as unknown as Project }); + reply.code(201); + return { + project: row, + bootstrap: { + folder_created: bootstrap.folder_created, + git_initialized: bootstrap.git_initialized, + first_commit: bootstrap.first_commit, + gitea_remote_created: bootstrap.gitea_remote_created, + gitea_pushed: bootstrap.gitea_pushed, + warnings: bootstrap.warnings, + }, + }; + } catch (err) { + app.log.error({ err, folder: bootstrap.folder_real_path }, 'project insert failed after bootstrap'); + reply.code(500); + return { + error: 'project created on disk but DB insert failed', + folder: bootstrap.folder_real_path, + }; + } + }); + app.post('/api/projects', async (req, reply) => { const parsed = AddProjectBody.safeParse(req.body); if (!parsed.success) { @@ -70,22 +155,88 @@ export function registerProjectRoutes( return { error: resolved.error }; } const name = parsed.data.name?.trim() || resolved.name; - try { - const [row] = await sql` - INSERT INTO projects (name, path) - VALUES (${name}, ${resolved.real}) - RETURNING id, name, path, added_at, last_session_id - `; + + // Pre-check the current row (if any) so we can distinguish three cases: + // - no row → INSERT fresh, 201, project_created + // - row archived → ON CONFLICT UPDATE flips to 'open', 200, project_unarchived + // - row already open → 409 (true duplicate) + const existing = await sql<{ status: string }[]>` + SELECT status FROM projects WHERE path = ${resolved.real} + `; + if (existing.length > 0 && existing[0]!.status === 'open') { + reply.code(409); + return { error: 'project already exists' }; + } + + const [row] = await sql` + INSERT INTO projects (name, path) + VALUES (${name}, ${resolved.real}) + ON CONFLICT (path) DO UPDATE SET status = 'open' + RETURNING id, name, path, added_at, last_session_id, status, gitea_remote + `; + + if (existing.length === 0) { broker.publishUser('default', { type: 'project_created', project: row as unknown as Project }); reply.code(201); - return row; - } catch (err) { - if (err instanceof Error && err.message.includes('duplicate key')) { - reply.code(409); - return { error: 'project already exists' }; - } - throw err; + } else { + // existing.status was 'archived' — row has been restored. + broker.publishUser('default', { type: 'project_unarchived', project: row as unknown as Project }); + reply.code(200); } + return row; + }); + + app.patch<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => { + const parsed = PatchProjectBody.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: 'invalid body', details: parsed.error.flatten() }; + } + const rows = await sql` + UPDATE projects SET name = ${parsed.data.name} + WHERE id = ${req.params.id} + RETURNING id, name, path, added_at, last_session_id, status, gitea_remote + `; + if (rows.length === 0) { + reply.code(404); + return { error: 'not found' }; + } + const project = rows[0]!; + broker.publishUser('default', { + type: 'project_updated', + project_id: project.id, + name: project.name, + }); + return project; + }); + + app.post<{ Params: { id: string } }>('/api/projects/:id/archive', async (req, reply) => { + const result = await sql` + UPDATE projects SET status = 'archived' + WHERE id = ${req.params.id} AND status = 'open' + `; + if (result.count === 0) { + reply.code(404); + return { error: 'not found or already archived' }; + } + broker.publishUser('default', { type: 'project_archived', project_id: req.params.id }); + reply.code(204); + return null; + }); + + app.post<{ Params: { id: string } }>('/api/projects/:id/unarchive', async (req, reply) => { + const rows = await sql` + UPDATE projects SET status = 'open' + WHERE id = ${req.params.id} AND status = 'archived' + RETURNING id, name, path, added_at, last_session_id, status, gitea_remote + `; + if (rows.length === 0) { + reply.code(404); + return { error: 'not found or not archived' }; + } + const project = rows[0]!; + broker.publishUser('default', { type: 'project_unarchived', project }); + return project; }); app.delete<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => { @@ -109,7 +260,12 @@ export function registerProjectRoutes( return [] as AvailableProject[]; } - const existing = await sql<{ path: string }[]>`SELECT path FROM projects`; + // Only exclude paths registered with status='open'. Archived projects' + // folders should reappear as available so re-add via the picker restores + // the existing row (see POST /api/projects ON CONFLICT below). + const existing = await sql<{ path: string }[]>` + SELECT path FROM projects WHERE status = 'open' + `; const existingSet = new Set(existing.map((r) => r.path)); const out: AvailableProject[] = []; @@ -143,7 +299,7 @@ export function registerProjectRoutes( const relPath = req.query.path ?? '.'; const rows = await sql` - SELECT id, name, path, added_at, last_session_id + SELECT id, name, path, added_at, last_session_id, status, gitea_remote FROM projects WHERE id = ${id} `; if (rows.length === 0) { @@ -188,7 +344,7 @@ export function registerProjectRoutes( } const rows = await sql` - SELECT id, name, path, added_at, last_session_id + SELECT id, name, path, added_at, last_session_id, status, gitea_remote FROM projects WHERE id = ${id} `; if (rows.length === 0) { @@ -232,7 +388,7 @@ export function registerProjectRoutes( const { id } = req.params; const rows = await sql` - SELECT id, name, path, added_at, last_session_id + SELECT id, name, path, added_at, last_session_id, status, gitea_remote FROM projects WHERE id = ${id} `; if (rows.length === 0) { diff --git a/apps/server/src/routes/sidebar.ts b/apps/server/src/routes/sidebar.ts index b265992..2256100 100644 --- a/apps/server/src/routes/sidebar.ts +++ b/apps/server/src/routes/sidebar.ts @@ -8,9 +8,10 @@ import type { export function registerSidebarRoutes(app: FastifyInstance, sql: Sql): void { app.get('/api/sidebar', async (): Promise => { - const projects = await sql<{ id: string; name: string }[]>` - SELECT id, name + const projects = await sql<{ id: string; name: string; path: string; gitea_remote: string | null }[]>` + SELECT id, name, path, gitea_remote FROM projects + WHERE status = 'open' ORDER BY added_at DESC `; @@ -33,6 +34,8 @@ export function registerSidebarRoutes(app: FastifyInstance, sql: Sql): void { return { id: p.id, name: p.name, + path: p.path, + gitea_remote: p.gitea_remote, recent_sessions, total_sessions: countRows[0]?.n ?? 0, }; diff --git a/apps/server/src/schema.sql b/apps/server/src/schema.sql index 60cea34..ce197eb 100644 --- a/apps/server/src/schema.sql +++ b/apps/server/src/schema.sql @@ -132,3 +132,15 @@ BEGIN CHECK (status IN ('streaming', 'complete', 'failed', 'cancelled')); END IF; END $$; + +-- v1.2-project-ux: projects.status + projects.gitea_remote +-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES +ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'; +ALTER TABLE projects ADD COLUMN IF NOT EXISTS gitea_remote TEXT; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'projects_status_chk') THEN + ALTER TABLE projects ADD CONSTRAINT projects_status_chk + CHECK (status IN ('open', 'archived')); + END IF; +END $$; diff --git a/apps/server/src/services/auto_name.ts b/apps/server/src/services/auto_name.ts index 46a6370..c1e43dd 100644 --- a/apps/server/src/services/auto_name.ts +++ b/apps/server/src/services/auto_name.ts @@ -53,7 +53,7 @@ export async function maybeAutoNameChat( AND status = 'complete' AND content <> '' `; - if (counts[0]?.n !== 1) return; + if ((counts[0]?.n ?? 0) < 1) return; const chatRows = await ctx.sql< { id: string; name: string | null; session_id: string }[] diff --git a/apps/server/src/services/gitea.ts b/apps/server/src/services/gitea.ts new file mode 100644 index 0000000..1277aed --- /dev/null +++ b/apps/server/src/services/gitea.ts @@ -0,0 +1,50 @@ +export interface GiteaConfig { + baseUrl: string; + user: string; + token: string; +} + +export interface GiteaRepo { + clone_url: string; + ssh_url: string; + html_url: string; +} + +export class GiteaRepoExistsError extends Error { + constructor() { + super('gitea-repo-exists'); + } +} + +export async function createGiteaRepo( + cfg: GiteaConfig, + name: string, + options: { private: boolean } +): Promise { + const res = await fetch(`${cfg.baseUrl}/api/v1/user/repos`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `token ${cfg.token}`, + }, + body: JSON.stringify({ + name, + private: options.private, + auto_init: false, + }), + }); + if (res.status === 409) throw new GiteaRepoExistsError(); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`gitea-api-${res.status}: ${text.slice(0, 200)}`); + } + const body = (await res.json()) as { ssh_url?: string; clone_url?: string; html_url?: string }; + if (!body.ssh_url || !body.html_url || !body.clone_url) { + throw new Error(`gitea-api-unexpected-shape: ${JSON.stringify(body).slice(0, 200)}`); + } + return { + ssh_url: body.ssh_url, + clone_url: body.clone_url, + html_url: body.html_url, + }; +} diff --git a/apps/server/src/services/project_bootstrap.ts b/apps/server/src/services/project_bootstrap.ts new file mode 100644 index 0000000..7762ce3 --- /dev/null +++ b/apps/server/src/services/project_bootstrap.ts @@ -0,0 +1,179 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdir, writeFile, realpath } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { resolve, sep } from 'node:path'; +import type { FastifyBaseLogger } from 'fastify'; +import type { Config } from '../config.js'; +import { createGiteaRepo, GiteaRepoExistsError } from './gitea.js'; + +const execFileAsync = promisify(execFile); + +const GITIGNORE_TEMPLATE = `# OS / editor +.DS_Store +*.swp +*~ + +# Node +node_modules/ +dist/ +build/ +.env +.env.local + +# Python +__pycache__/ +*.pyc +.venv/ +venv/ + +# AI agents +.claude/ +.opencode/ + +# Backups +*.bak* +`; + +const GIT_USER_NAME = 'indifferentketchup'; +const GIT_USER_EMAIL = 'samkintop@gmail.com'; + +export interface BootstrapResult { + folder_real_path: string; + folder_name: string; + gitea_remote_url: string | null; + folder_created: boolean; + git_initialized: boolean; + first_commit: boolean; + gitea_remote_created: boolean; + gitea_pushed: boolean; + warnings: string[]; +} + +const SAFE_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/; + +export function sanitizeFolderName(raw: string): string { + return raw + .toLowerCase() + .trim() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); +} + +export class BootstrapNameError extends Error {} +export class BootstrapCollisionError extends Error {} +export class BootstrapPathError extends Error {} + +export async function bootstrapProject( + config: Config, + log: FastifyBaseLogger, + options: { + name: string; + commitMessage: string; + visibility: 'private' | 'public'; + createGiteaRemote: boolean; + } +): Promise { + const folder = sanitizeFolderName(options.name); + if (folder.length === 0 || !SAFE_NAME.test(folder)) { + throw new BootstrapNameError(`invalid name after sanitization: "${folder}"`); + } + + // Whitelist resolution + const whitelistReal = await realpath(config.PROJECT_ROOT_WHITELIST); + const fullPath = resolve(whitelistReal, folder); + if (!fullPath.startsWith(whitelistReal + sep)) { + throw new BootstrapPathError('path escapes whitelist'); + } + if (existsSync(fullPath)) { + throw new BootstrapCollisionError(`path already exists: ${fullPath}`); + } + + const warnings: string[] = []; + let folder_created = false; + let git_initialized = false; + let first_commit = false; + let gitea_remote_created = false; + let gitea_pushed = false; + let gitea_remote_url: string | null = null; + + // Step 1: mkdir + await mkdir(fullPath, { recursive: false }); + folder_created = true; + log.info({ fullPath }, 'project_bootstrap: folder created'); + + // Step 2: write .gitignore + await writeFile(resolve(fullPath, '.gitignore'), GITIGNORE_TEMPLATE, 'utf8'); + + // Step 3: git init -b main + await execFileAsync('git', ['init', '-b', 'main'], { cwd: fullPath }); + git_initialized = true; + + // Step 4: git add + commit (per-command -c, no global config touch) + await execFileAsync('git', ['add', '.gitignore'], { cwd: fullPath }); + await execFileAsync( + 'git', + [ + '-c', `user.name=${GIT_USER_NAME}`, + '-c', `user.email=${GIT_USER_EMAIL}`, + 'commit', + '-m', options.commitMessage, + ], + { cwd: fullPath } + ); + first_commit = true; + log.info({ folder }, 'project_bootstrap: initial commit'); + + // Step 5: optional Gitea remote + if (options.createGiteaRemote) { + if (!config.GITEA_TOKEN) { + warnings.push('Gitea remote skipped — token not configured'); + } else { + try { + const repo = await createGiteaRepo( + { baseUrl: config.GITEA_BASE_URL, user: config.GITEA_USER, token: config.GITEA_TOKEN }, + folder, + { private: options.visibility === 'private' } + ); + gitea_remote_created = true; + gitea_remote_url = repo.html_url; + log.info({ folder, html_url: repo.html_url }, 'project_bootstrap: gitea repo created'); + + // Step 6: git remote add + push + try { + await execFileAsync('git', ['remote', 'add', 'origin', repo.ssh_url], { cwd: fullPath }); + await execFileAsync('git', ['push', '-u', 'origin', 'main'], { cwd: fullPath }); + gitea_pushed = true; + log.info({ folder }, 'project_bootstrap: pushed to gitea'); + } catch (pushErr) { + const msg = pushErr instanceof Error ? pushErr.message : String(pushErr); + warnings.push(`Push to Gitea failed: ${msg.slice(0, 200)}`); + log.warn({ err: pushErr, folder }, 'project_bootstrap: push failed'); + } + } catch (err) { + if (err instanceof GiteaRepoExistsError) { + warnings.push('Gitea repo already exists with this name; local repo created without remote'); + } else { + const msg = err instanceof Error ? err.message : String(err); + warnings.push(`Gitea remote creation failed: ${msg.slice(0, 200)}`); + } + log.warn({ err, folder }, 'project_bootstrap: gitea remote step failed'); + } + } + } + + return { + folder_real_path: fullPath, + folder_name: folder, + gitea_remote_url, + folder_created, + git_initialized, + first_commit, + gitea_remote_created, + gitea_pushed, + warnings, + }; +} diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index 54eabfe..398a8d7 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -1,9 +1,15 @@ +// KEEP IN SYNC: apps/server/src/schema.sql projects_status_chk +export const PROJECT_STATUSES = ['open', 'archived'] as const; +export type ProjectStatus = typeof PROJECT_STATUSES[number]; + export interface Project { id: string; name: string; path: string; added_at: string; last_session_id: string | null; + status: ProjectStatus; + gitea_remote: string | null; } export interface AvailableProject { @@ -97,6 +103,8 @@ export interface SidebarSession { export interface SidebarProject { id: string; name: string; + path: string; + gitea_remote: string | null; recent_sessions: SidebarSession[]; total_sessions: number; } @@ -188,6 +196,19 @@ export interface ChatClosedFrame { chat_id: string; session_id: string; } +export interface ProjectArchivedFrame { + type: 'project_archived'; + project_id: string; +} +export interface ProjectUnarchivedFrame { + type: 'project_unarchived'; + project: Project; +} +export interface ProjectUpdatedFrame { + type: 'project_updated'; + project_id: string; + name: string; +} export type UserStreamFrame = | ProjectCreatedFrame | ProjectDeletedFrame @@ -197,4 +218,7 @@ export type UserStreamFrame = | SessionArchivedFrame | ChatCreatedFrame | ChatUpdatedFrame - | ChatClosedFrame; + | ChatClosedFrame + | ProjectArchivedFrame + | ProjectUnarchivedFrame + | ProjectUpdatedFrame; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 828e17a..3aaec43 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -41,13 +41,43 @@ export const api = { health: () => request<{ status: string; db: boolean }>('/api/health'), projects: { - list: () => request('/api/projects'), + list: (params?: { status?: 'open' | 'archived' }) => + request(`/api/projects${params?.status ? `?status=${params.status}` : ''}`), available: () => request('/api/projects/available'), add: (body: { path: string; name?: string }) => request('/api/projects', { method: 'POST', body: JSON.stringify(body), }), + update: (id: string, body: { name: string }) => + request(`/api/projects/${id}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), + archive: (id: string) => + request(`/api/projects/${id}/archive`, { method: 'POST' }), + unarchive: (id: string) => + request(`/api/projects/${id}/unarchive`, { method: 'POST' }), + create: (body: { + name: string; + commit_message?: string; + visibility?: 'private' | 'public'; + create_gitea_remote?: boolean; + }) => + request<{ + project: Project; + bootstrap: { + folder_created: boolean; + git_initialized: boolean; + first_commit: boolean; + gitea_remote_created: boolean; + gitea_pushed: boolean; + warnings: string[]; + }; + }>(`/api/projects/create`, { + method: 'POST', + body: JSON.stringify(body), + }), remove: (id: string) => request(`/api/projects/${id}`, { method: 'DELETE' }), listDir: (id: string, path: string) => diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 46d06e1..e700951 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -1,9 +1,14 @@ +export const PROJECT_STATUSES = ['open', 'archived'] as const; +export type ProjectStatus = typeof PROJECT_STATUSES[number]; + export interface Project { id: string; name: string; path: string; added_at: string; last_session_id: string | null; + status: ProjectStatus; + gitea_remote: string | null; } export interface AvailableProject { @@ -91,6 +96,8 @@ export interface SidebarSession { export interface SidebarProject { id: string; name: string; + path: string; + gitea_remote: string | null; recent_sessions: SidebarSession[]; total_sessions: number; } diff --git a/apps/web/src/components/CreateProjectModal.tsx b/apps/web/src/components/CreateProjectModal.tsx new file mode 100644 index 0000000..e1d7c56 --- /dev/null +++ b/apps/web/src/components/CreateProjectModal.tsx @@ -0,0 +1,171 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; +import { api } from '@/api/client'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function previewFolderName(raw: string): string { + return raw + .toLowerCase() + .trim() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); +} + +export function CreateProjectModal({ open, onOpenChange }: Props) { + const navigate = useNavigate(); + const [name, setName] = useState(''); + const [commitMessage, setCommitMessage] = useState('Initial commit'); + const [visibility, setVisibility] = useState<'private' | 'public'>('private'); + const [createRemote, setCreateRemote] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setName(''); + setCommitMessage('Initial commit'); + setVisibility('private'); + setCreateRemote(true); + setBusy(false); + setError(null); + }, [open]); + + const folderPreview = previewFolderName(name); + + async function submit() { + if (!folderPreview) { + setError('Project name must contain at least one letter or digit.'); + return; + } + setBusy(true); + setError(null); + try { + const result = await api.projects.create({ + name: name.trim(), + commit_message: commitMessage.trim() || 'Initial commit', + visibility, + create_gitea_remote: createRemote, + }); + const warnings = result.bootstrap.warnings; + if (warnings.length > 0) { + toast.warning(`Project created with warnings: ${warnings.join('; ')}`); + } else { + toast.success(`Project "${result.project.name}" created`); + } + onOpenChange(false); + navigate(`/project/${result.project.id}`); + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to create project'); + } finally { + setBusy(false); + } + } + + return ( + + + + Create New Project + + Creates a folder under /opt with a git repo, .gitignore, and optionally a Gitea remote. + + + +
+
+ + setName(e.target.value)} + disabled={busy} + autoFocus + /> + {name && ( +
+ Folder: /opt/{folderPreview || (empty after sanitization)} +
+ )} +
+ +
+ + setCommitMessage(e.target.value)} + disabled={busy} + /> +
+ +
+ +
+ + +
+
+ + + + {error && ( +
{error}
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/web/src/components/ProjectSidebar.tsx b/apps/web/src/components/ProjectSidebar.tsx index 4d8efc1..92b5161 100644 --- a/apps/web/src/components/ProjectSidebar.tsx +++ b/apps/web/src/components/ProjectSidebar.tsx @@ -1,14 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; -import { ChevronRight, Folder, MessageSquare, Plus } from 'lucide-react'; +import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; import { ContextMenu, ContextMenuContent, @@ -28,6 +22,7 @@ import { api } from '@/api/client'; import { sessionEvents } from '@/hooks/sessionEvents'; import { useSidebar } from '@/hooks/useSidebar'; import type { SidebarProject } from '@/api/types'; +import { giteaUrlFor } from '@/lib/projectUrls'; import { cn } from '@/lib/utils'; const EXPANDED_KEY = 'boocode.sidebar.expanded'; @@ -108,6 +103,9 @@ export function ProjectSidebar() { const [renamingSession, setRenamingSession] = useState(null); const [renameValue, setRenameValue] = useState(''); const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null); + const [renamingProject, setRenamingProject] = useState(null); + const [renameProjectValue, setRenameProjectValue] = useState(''); + const [archiveProjectConfirm, setArchiveProjectConfirm] = useState<{ id: string; name: string } | null>(null); const navigate = useNavigate(); const location = useLocation(); const lastToastedError = useRef(null); @@ -140,13 +138,25 @@ export function ProjectSidebar() { }); } - async function handleRemove(id: string) { + async function handleArchiveProject(id: string) { try { - await api.projects.remove(id); - // Server publishes project_deleted via WS; useUserEvents delivers it. - navigate('/'); + await api.projects.archive(id); + // Server publishes project_archived via WS. + if (activeProject === id) navigate('/'); } catch (err) { - toast.error(err instanceof Error ? err.message : 'failed to remove project'); + toast.error(err instanceof Error ? err.message : 'failed to archive project'); + } + } + + async function handleRenameProject(id: string) { + const trimmed = renameProjectValue.trim(); + setRenamingProject(null); + if (!trimmed) return; + try { + await api.projects.update(id, { name: trimmed }); + // Server publishes project_updated via WS. + } catch (err) { + toast.error(err instanceof Error ? err.message : 'failed to rename project'); } } @@ -224,52 +234,73 @@ export function ProjectSidebar() { const visible = p.recent_sessions.slice(0, MAX_VISIBLE_SESSIONS); return (
- -
{ - e.preventDefault(); - ( - e.currentTarget.parentElement?.querySelector( - '[data-ctxtrigger]' - ) as HTMLElement | null - )?.click(); - }} - > - - - - {p.name} - -
- - + {renamingProject === p.id ? ( +
+ + setRenameProjectValue(e.target.value)} + onBlur={() => void handleRenameProject(p.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleRenameProject(p.id); + if (e.key === 'Escape') setRenamingProject(null); + }} + className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0" + /> +
+ ) : ( + + + {p.name} + + )} +
+ + + { + setRenamingProject(p.id); + setRenameProjectValue(p.name); + }}> + Rename + + setArchiveProjectConfirm({ id: p.id, name: p.name })}> + Archive + + + { + const url = giteaUrlFor({ path: p.path, gitea_remote: p.gitea_remote }); + window.open(url, '_blank', 'noopener'); + }}> + Open in Gitea + + + {isExpanded && (
@@ -341,6 +372,30 @@ export function ProjectSidebar() { {}} /> + { if (!open) setArchiveProjectConfirm(null); }}> + + + Archive project? + + Removes {archiveProjectConfirm ? `"${archiveProjectConfirm.name}"` : 'this project'} from the sidebar. Files on disk are untouched. You can restore it later from the Archived Projects view. + + +
+ + +
+
+
+ { if (!open) setDeleteConfirm(null); }}> diff --git a/apps/web/src/hooks/sessionEvents.ts b/apps/web/src/hooks/sessionEvents.ts index c7984fb..5bb9813 100644 --- a/apps/web/src/hooks/sessionEvents.ts +++ b/apps/web/src/hooks/sessionEvents.ts @@ -83,6 +83,22 @@ export interface ChatClosedEvent { session_id: string; } +export interface ProjectArchivedEvent { + type: 'project_archived'; + project_id: string; +} + +export interface ProjectUnarchivedEvent { + type: 'project_unarchived'; + project: Project; +} + +export interface ProjectUpdatedEvent { + type: 'project_updated'; + project_id: string; + name: string; +} + export type SessionEvent = | SessionRenamedEvent | ProjectCreatedEvent @@ -96,7 +112,10 @@ export type SessionEvent = | SessionArchivedEvent | ChatCreatedEvent | ChatUpdatedEvent - | ChatClosedEvent; + | ChatClosedEvent + | ProjectArchivedEvent + | ProjectUnarchivedEvent + | ProjectUpdatedEvent; type Listener = (event: SessionEvent) => void; const listeners = new Set(); diff --git a/apps/web/src/hooks/useSidebar.ts b/apps/web/src/hooks/useSidebar.ts index ec861e9..e1c0ac8 100644 --- a/apps/web/src/hooks/useSidebar.ts +++ b/apps/web/src/hooks/useSidebar.ts @@ -56,6 +56,8 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess const fresh: SidebarProject = { id: event.project.id, name: event.project.name, + path: event.project.path, + gitea_remote: event.project.gitea_remote ?? null, recent_sessions: [], total_sessions: 0, }; @@ -165,6 +167,33 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess case 'chat_updated': case 'chat_closed': return prev; + case 'project_archived': { + const next = prev.projects.filter((p) => p.id !== event.project_id); + if (next.length === prev.projects.length) return prev; + return { ...prev, projects: next }; + } + case 'project_unarchived': { + if (prev.projects.some((p) => p.id === event.project.id)) return prev; + const fresh: SidebarProject = { + id: event.project.id, + name: event.project.name, + path: event.project.path, + gitea_remote: event.project.gitea_remote ?? null, + recent_sessions: [], + total_sessions: 0, + }; + return { ...prev, projects: [fresh, ...prev.projects] }; + } + case 'project_updated': { + let changed = false; + const projects = prev.projects.map((p) => { + if (p.id !== event.project_id) return p; + if (p.name === event.name) return p; + changed = true; + return { ...p, name: event.name }; + }); + return changed ? { ...prev, projects } : prev; + } } } diff --git a/apps/web/src/lib/projectUrls.ts b/apps/web/src/lib/projectUrls.ts new file mode 100644 index 0000000..96f6099 --- /dev/null +++ b/apps/web/src/lib/projectUrls.ts @@ -0,0 +1,5 @@ +export function giteaUrlFor(project: { path: string; gitea_remote?: string | null }): string { + if (project.gitea_remote) return project.gitea_remote; + const folderName = project.path.split('/').filter(Boolean).pop() ?? ''; + return `https://git.indifferentketchup.com/indifferentketchup/${folderName}`; +} diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index 189399a..f9079a4 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -1,35 +1,135 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { ChevronDown, ChevronRight, Folder, RotateCcw } from 'lucide-react'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { AddProjectModal } from '@/components/AddProjectModal'; +import { CreateProjectModal } from '@/components/CreateProjectModal'; +import { api } from '@/api/client'; +import type { Project } from '@/api/types'; +import { sessionEvents } from '@/hooks/sessionEvents'; import { useSidebar } from '@/hooks/useSidebar'; export function Home() { const { data } = useSidebar(); - const [open, setOpen] = useState(false); + const [addOpen, setAddOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [archived, setArchived] = useState(null); + const [showArchived, setShowArchived] = useState(false); const empty = data ? data.projects.length === 0 : false; + useEffect(() => { + api.projects.list({ status: 'archived' }) + .then(setArchived) + .catch(() => {}); + }, []); + + useEffect(() => { + return sessionEvents.subscribe((event) => { + if (event.type === 'project_archived') { + setArchived((prev) => { + if (!prev) return prev; + if (prev.some((p) => p.id === event.project_id)) return prev; + const fromSidebar = data?.projects.find((p) => p.id === event.project_id); + if (!fromSidebar) return prev; + return [ + { + id: fromSidebar.id, + name: fromSidebar.name, + path: fromSidebar.path, + added_at: new Date().toISOString(), + last_session_id: null, + status: 'archived' as const, + gitea_remote: fromSidebar.gitea_remote, + }, + ...prev, + ]; + }); + } + if (event.type === 'project_unarchived') { + setArchived((prev) => prev ? prev.filter((p) => p.id !== event.project.id) : prev); + } + if (event.type === 'project_deleted') { + setArchived((prev) => prev ? prev.filter((p) => p.id !== event.project_id) : prev); + } + if (event.type === 'project_updated') { + setArchived((prev) => + prev ? prev.map((p) => p.id === event.project_id ? { ...p, name: event.name } : p) : prev + ); + } + }); + }, [data]); + + async function handleUnarchive(id: string) { + try { + await api.projects.unarchive(id); + // Server publishes project_unarchived; useUserEvents delivers it. + } catch (err) { + toast.error(err instanceof Error ? err.message : 'failed to restore project'); + } + } + return ( -
-
- {empty ? ( - <> -

No projects yet

-

- Add a project from /opt to start chatting about its code. -

- - - ) : ( - <> -

BooCode

-

- Pick a project from the sidebar. -

- +
+
+
+ {empty ? ( + <> +

No projects yet

+

+ Add a project from /opt or create a new one. +

+ + ) : ( + <> +

BooCode

+

+ Pick a project from the sidebar, or add another. +

+ + )} +
+ + +
+
+ + {archived && archived.length > 0 && ( +
+ + {showArchived && ( +
    + {archived.map((p) => ( +
  • +
    + + {p.name} +
    + +
  • + ))} +
+ )} +
)}
- {}} /> + {}} /> +
); } diff --git a/docker-compose.yml b/docker-compose.yml index 22bbb7f..2ca91e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: environment: DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode volumes: - - /opt:/opt:ro + - /opt:/opt:rw depends_on: - boocode_db networks: