project-ux: archive/rename/Open-in-Gitea sidebar context menu, archived projects landing, create-project bootstrap with Gitea remote

Server:
- projects.status + projects.gitea_remote (additive) with CHECK ('open','archived')
- GET /api/projects?status=archived; PATCH /api/projects/:id (rename);
  POST /api/projects/:id/archive | unarchive; POST /api/projects/create
- POST /api/projects ON CONFLICT (path) DO UPDATE SET status='open': re-add
  of archived path restores existing row (preserves id + FKs); already-open
  path returns 409. Detected-repos picker now excludes only status='open'.
- New gitea.ts (createGiteaRepo + GiteaRepoExistsError) and
  project_bootstrap.ts (sanitize name, mkdir under PROJECT_ROOT_WHITELIST,
  git init -b main + first commit with -c user.name/email per-command, optional
  Gitea repo create + remote add + push; all via execFile, no shell).
- 3 new user-stream frames: project_archived, project_unarchived, project_updated.
- sidebar.ts now selects path + gitea_remote and filters status='open'.
- Gitea env added to config.ts (GITEA_BASE_URL, GITEA_USER, GITEA_TOKEN,
  GITEA_SSH_HOST).
- docker-compose.yml /opt mount flipped to rw so create-project can mkdir.
- auto_name.ts gate relaxed from `!== 1` to `< 1` (fires on every turn while
  chat name is empty, not only the first).

Web:
- ProjectSidebar: project rows use proper Radix ContextMenu; items Rename /
  Archive / Open in Gitea. Inline rename, archive confirm dialog.
  Removed obsolete handleRemove + DropdownMenu hack.
- Home: Add-existing + Create-new buttons; collapsible Archived Projects
  section with Restore.
- New CreateProjectModal: name + live folder preview, commit msg, Private/
  Public radio, create-Gitea-remote checkbox, toast on success/warnings.
- New projectUrls.ts giteaUrlFor() — uses gitea_remote when present,
  falls back to convention URL.
- 3 new event types in sessionEvents.ts with idempotent useSidebar handlers.
- SidebarProject extended with path + gitea_remote so Open-in-Gitea can
  resolve without a separate fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 02:51:59 +00:00
parent 051f3b96ae
commit 48a972e139
17 changed files with 947 additions and 103 deletions

View File

@@ -9,6 +9,10 @@ const ConfigSchema = z.object({
PROJECT_ROOT_WHITELIST: z.string().default('/opt'), PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'), DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'),
LOG_LEVEL: z.string().default('info'), 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<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -9,12 +9,29 @@ import type { Project, AvailableProject } from '../types/api.js';
import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js'; import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js';
import { listDir, viewFile } from '../services/file_ops.js'; import { listDir, viewFile } from '../services/file_ops.js';
import { getProjectFiles } from '../services/file_index.js'; import { getProjectFiles } from '../services/file_index.js';
import {
bootstrapProject,
BootstrapNameError,
BootstrapCollisionError,
BootstrapPathError,
} from '../services/project_bootstrap.js';
const AddProjectBody = z.object({ const AddProjectBody = z.object({
path: z.string().min(1), path: z.string().min(1),
name: z.string().min(1).optional(), 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<boolean> { async function isDir(path: string): Promise<boolean> {
try { try {
const s = await stat(path); const s = await stat(path);
@@ -49,15 +66,83 @@ export function registerProjectRoutes(
config: Config, config: Config,
broker: Broker broker: Broker
): void { ): 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<Project[]>` const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id SELECT id, name, path, added_at, last_session_id, status, gitea_remote
FROM projects FROM projects
WHERE status = ${status}
ORDER BY added_at DESC ORDER BY added_at DESC
`; `;
return rows; 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<Project[]>`
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) => { app.post('/api/projects', async (req, reply) => {
const parsed = AddProjectBody.safeParse(req.body); const parsed = AddProjectBody.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@@ -70,22 +155,88 @@ export function registerProjectRoutes(
return { error: resolved.error }; return { error: resolved.error };
} }
const name = parsed.data.name?.trim() || resolved.name; const name = parsed.data.name?.trim() || resolved.name;
try {
const [row] = await sql<Project[]>` // Pre-check the current row (if any) so we can distinguish three cases:
INSERT INTO projects (name, path) // - no row INSERT fresh, 201, project_created
VALUES (${name}, ${resolved.real}) // - row archived → ON CONFLICT UPDATE flips to 'open', 200, project_unarchived
RETURNING id, name, path, added_at, last_session_id // - 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<Project[]>`
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 }); broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
reply.code(201); reply.code(201);
return row; } else {
} catch (err) { // existing.status was 'archived' — row has been restored.
if (err instanceof Error && err.message.includes('duplicate key')) { broker.publishUser('default', { type: 'project_unarchived', project: row as unknown as Project });
reply.code(409); reply.code(200);
return { error: 'project already exists' };
}
throw err;
} }
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<Project[]>`
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<Project[]>`
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) => { app.delete<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
@@ -109,7 +260,12 @@ export function registerProjectRoutes(
return [] as AvailableProject[]; 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 existingSet = new Set(existing.map((r) => r.path));
const out: AvailableProject[] = []; const out: AvailableProject[] = [];
@@ -143,7 +299,7 @@ export function registerProjectRoutes(
const relPath = req.query.path ?? '.'; const relPath = req.query.path ?? '.';
const rows = await sql<Project[]>` const rows = await sql<Project[]>`
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} FROM projects WHERE id = ${id}
`; `;
if (rows.length === 0) { if (rows.length === 0) {
@@ -188,7 +344,7 @@ export function registerProjectRoutes(
} }
const rows = await sql<Project[]>` const rows = await sql<Project[]>`
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} FROM projects WHERE id = ${id}
`; `;
if (rows.length === 0) { if (rows.length === 0) {
@@ -232,7 +388,7 @@ export function registerProjectRoutes(
const { id } = req.params; const { id } = req.params;
const rows = await sql<Project[]>` const rows = await sql<Project[]>`
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} FROM projects WHERE id = ${id}
`; `;
if (rows.length === 0) { if (rows.length === 0) {

View File

@@ -8,9 +8,10 @@ import type {
export function registerSidebarRoutes(app: FastifyInstance, sql: Sql): void { export function registerSidebarRoutes(app: FastifyInstance, sql: Sql): void {
app.get('/api/sidebar', async (): Promise<SidebarResponse> => { app.get('/api/sidebar', async (): Promise<SidebarResponse> => {
const projects = await sql<{ id: string; name: string }[]>` const projects = await sql<{ id: string; name: string; path: string; gitea_remote: string | null }[]>`
SELECT id, name SELECT id, name, path, gitea_remote
FROM projects FROM projects
WHERE status = 'open'
ORDER BY added_at DESC ORDER BY added_at DESC
`; `;
@@ -33,6 +34,8 @@ export function registerSidebarRoutes(app: FastifyInstance, sql: Sql): void {
return { return {
id: p.id, id: p.id,
name: p.name, name: p.name,
path: p.path,
gitea_remote: p.gitea_remote,
recent_sessions, recent_sessions,
total_sessions: countRows[0]?.n ?? 0, total_sessions: countRows[0]?.n ?? 0,
}; };

View File

@@ -132,3 +132,15 @@ BEGIN
CHECK (status IN ('streaming', 'complete', 'failed', 'cancelled')); CHECK (status IN ('streaming', 'complete', 'failed', 'cancelled'));
END IF; END IF;
END $$; 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 $$;

View File

@@ -53,7 +53,7 @@ export async function maybeAutoNameChat(
AND status = 'complete' AND status = 'complete'
AND content <> '' AND content <> ''
`; `;
if (counts[0]?.n !== 1) return; if ((counts[0]?.n ?? 0) < 1) return;
const chatRows = await ctx.sql< const chatRows = await ctx.sql<
{ id: string; name: string | null; session_id: string }[] { id: string; name: string | null; session_id: string }[]

View File

@@ -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<GiteaRepo> {
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,
};
}

View File

@@ -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<BootstrapResult> {
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,
};
}

View File

@@ -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 { export interface Project {
id: string; id: string;
name: string; name: string;
path: string; path: string;
added_at: string; added_at: string;
last_session_id: string | null; last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
} }
export interface AvailableProject { export interface AvailableProject {
@@ -97,6 +103,8 @@ export interface SidebarSession {
export interface SidebarProject { export interface SidebarProject {
id: string; id: string;
name: string; name: string;
path: string;
gitea_remote: string | null;
recent_sessions: SidebarSession[]; recent_sessions: SidebarSession[];
total_sessions: number; total_sessions: number;
} }
@@ -188,6 +196,19 @@ export interface ChatClosedFrame {
chat_id: string; chat_id: string;
session_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 = export type UserStreamFrame =
| ProjectCreatedFrame | ProjectCreatedFrame
| ProjectDeletedFrame | ProjectDeletedFrame
@@ -197,4 +218,7 @@ export type UserStreamFrame =
| SessionArchivedFrame | SessionArchivedFrame
| ChatCreatedFrame | ChatCreatedFrame
| ChatUpdatedFrame | ChatUpdatedFrame
| ChatClosedFrame; | ChatClosedFrame
| ProjectArchivedFrame
| ProjectUnarchivedFrame
| ProjectUpdatedFrame;

View File

@@ -41,13 +41,43 @@ export const api = {
health: () => request<{ status: string; db: boolean }>('/api/health'), health: () => request<{ status: string; db: boolean }>('/api/health'),
projects: { projects: {
list: () => request<Project[]>('/api/projects'), list: (params?: { status?: 'open' | 'archived' }) =>
request<Project[]>(`/api/projects${params?.status ? `?status=${params.status}` : ''}`),
available: () => request<AvailableProject[]>('/api/projects/available'), available: () => request<AvailableProject[]>('/api/projects/available'),
add: (body: { path: string; name?: string }) => add: (body: { path: string; name?: string }) =>
request<Project>('/api/projects', { request<Project>('/api/projects', {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
update: (id: string, body: { name: string }) =>
request<Project>(`/api/projects/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
archive: (id: string) =>
request<void>(`/api/projects/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Project>(`/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) => remove: (id: string) =>
request<void>(`/api/projects/${id}`, { method: 'DELETE' }), request<void>(`/api/projects/${id}`, { method: 'DELETE' }),
listDir: (id: string, path: string) => listDir: (id: string, path: string) =>

View File

@@ -1,9 +1,14 @@
export const PROJECT_STATUSES = ['open', 'archived'] as const;
export type ProjectStatus = typeof PROJECT_STATUSES[number];
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
path: string; path: string;
added_at: string; added_at: string;
last_session_id: string | null; last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
} }
export interface AvailableProject { export interface AvailableProject {
@@ -91,6 +96,8 @@ export interface SidebarSession {
export interface SidebarProject { export interface SidebarProject {
id: string; id: string;
name: string; name: string;
path: string;
gitea_remote: string | null;
recent_sessions: SidebarSession[]; recent_sessions: SidebarSession[];
total_sessions: number; total_sessions: number;
} }

View File

@@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
<DialogDescription>
Creates a folder under /opt with a git repo, .gitignore, and optionally a Gitea remote.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="proj-name">Project name</Label>
<Input
id="proj-name"
placeholder="My new project"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={busy}
autoFocus
/>
{name && (
<div className="text-xs text-muted-foreground font-mono">
Folder: /opt/{folderPreview || <span className="text-destructive">(empty after sanitization)</span>}
</div>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="commit-msg">Initial commit message</Label>
<Input
id="commit-msg"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
disabled={busy}
/>
</div>
<div className="space-y-1.5">
<Label>Visibility</Label>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-1.5">
<input
type="radio"
checked={visibility === 'private'}
onChange={() => setVisibility('private')}
disabled={busy}
/>
Private
</label>
<label className="flex items-center gap-1.5">
<input
type="radio"
checked={visibility === 'public'}
onChange={() => setVisibility('public')}
disabled={busy}
/>
Public
</label>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={createRemote}
onChange={(e) => setCreateRemote(e.target.checked)}
disabled={busy}
/>
Create Gitea remote and push
</label>
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
</Button>
<Button onClick={() => void submit()} disabled={busy || !folderPreview}>
{busy ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,14 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom'; 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 { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -28,6 +22,7 @@ import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useSidebar } from '@/hooks/useSidebar'; import { useSidebar } from '@/hooks/useSidebar';
import type { SidebarProject } from '@/api/types'; import type { SidebarProject } from '@/api/types';
import { giteaUrlFor } from '@/lib/projectUrls';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const EXPANDED_KEY = 'boocode.sidebar.expanded'; const EXPANDED_KEY = 'boocode.sidebar.expanded';
@@ -108,6 +103,9 @@ export function ProjectSidebar() {
const [renamingSession, setRenamingSession] = useState<string | null>(null); const [renamingSession, setRenamingSession] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState(''); const [renameValue, setRenameValue] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
const [renamingProject, setRenamingProject] = useState<string | null>(null);
const [renameProjectValue, setRenameProjectValue] = useState('');
const [archiveProjectConfirm, setArchiveProjectConfirm] = useState<{ id: string; name: string } | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const lastToastedError = useRef<string | null>(null); const lastToastedError = useRef<string | null>(null);
@@ -140,13 +138,25 @@ export function ProjectSidebar() {
}); });
} }
async function handleRemove(id: string) { async function handleArchiveProject(id: string) {
try { try {
await api.projects.remove(id); await api.projects.archive(id);
// Server publishes project_deleted via WS; useUserEvents delivers it. // Server publishes project_archived via WS.
navigate('/'); if (activeProject === id) navigate('/');
} catch (err) { } 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); const visible = p.recent_sessions.slice(0, MAX_VISIBLE_SESSIONS);
return ( return (
<div key={p.id} className="px-2"> <div key={p.id} className="px-2">
<DropdownMenu> <ContextMenu>
<div <ContextMenuTrigger asChild>
className={`group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${rowCls(isActiveProject)}`} <div
onContextMenu={(e) => { className={`group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${rowCls(isActiveProject)}`}
e.preventDefault();
(
e.currentTarget.parentElement?.querySelector(
'[data-ctxtrigger]'
) as HTMLElement | null
)?.click();
}}
>
<button
type="button"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
aria-expanded={isExpanded}
disabled={isActiveProject}
onClick={(e) => {
e.stopPropagation();
if (isActiveProject) return;
toggle(p.id);
}}
className={cn(
'flex items-center justify-center size-4 shrink-0 opacity-70 hover:opacity-100',
isActiveProject &&
'opacity-50 cursor-not-allowed hover:opacity-50'
)}
> >
<ChevronRight <button
className={`size-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`} type="button"
/> aria-label={isExpanded ? 'Collapse' : 'Expand'}
</button> aria-expanded={isExpanded}
<NavLink to={`/project/${p.id}`} className="flex items-center gap-2 min-w-0 flex-1"> disabled={isActiveProject}
<Folder className="size-3.5 shrink-0 opacity-70" /> onClick={(e) => {
<span className="truncate" title={p.name}>{p.name}</span> e.stopPropagation();
</NavLink> if (isActiveProject) return;
</div> toggle(p.id);
<DropdownMenuTrigger asChild> }}
<button data-ctxtrigger className="hidden" aria-hidden /> className={cn(
</DropdownMenuTrigger> 'flex items-center justify-center size-4 shrink-0 opacity-70 hover:opacity-100',
<DropdownMenuContent align="start"> isActiveProject &&
<DropdownMenuItem variant="destructive" onClick={() => void handleRemove(p.id)}> 'opacity-50 cursor-not-allowed hover:opacity-50'
Remove from sidebar )}
</DropdownMenuItem> >
</DropdownMenuContent> <ChevronRight
</DropdownMenu> className={`size-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
{renamingProject === p.id ? (
<div className="flex items-center gap-2 min-w-0 flex-1">
<Folder className="size-3.5 shrink-0 opacity-70" />
<input
autoFocus
value={renameProjectValue}
onChange={(e) => 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"
/>
</div>
) : (
<NavLink to={`/project/${p.id}`} className="flex items-center gap-2 min-w-0 flex-1">
<Folder className="size-3.5 shrink-0 opacity-70" />
<span className="truncate" title={p.name}>{p.name}</span>
</NavLink>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => {
setRenamingProject(p.id);
setRenameProjectValue(p.name);
}}>
Rename
</ContextMenuItem>
<ContextMenuItem onSelect={() => setArchiveProjectConfirm({ id: p.id, name: p.name })}>
Archive
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => {
const url = giteaUrlFor({ path: p.path, gitea_remote: p.gitea_remote });
window.open(url, '_blank', 'noopener');
}}>
<ExternalLink size={12} /> Open in Gitea
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{isExpanded && ( {isExpanded && (
<div className="ml-5 mt-0.5 space-y-0.5"> <div className="ml-5 mt-0.5 space-y-0.5">
@@ -341,6 +372,30 @@ export function ProjectSidebar() {
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} /> <AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<Dialog open={archiveProjectConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveProjectConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive project?</DialogTitle>
<DialogDescription>
Removes {archiveProjectConfirm ? `"${archiveProjectConfirm.name}"` : 'this project'} from the sidebar. Files on disk are untouched. You can restore it later from the Archived Projects view.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setArchiveProjectConfirm(null)}>
Cancel
</Button>
<Button
onClick={() => {
if (archiveProjectConfirm) void handleArchiveProject(archiveProjectConfirm.id);
setArchiveProjectConfirm(null);
}}
>
Archive
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}> <Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>

View File

@@ -83,6 +83,22 @@ export interface ChatClosedEvent {
session_id: string; 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 = export type SessionEvent =
| SessionRenamedEvent | SessionRenamedEvent
| ProjectCreatedEvent | ProjectCreatedEvent
@@ -96,7 +112,10 @@ export type SessionEvent =
| SessionArchivedEvent | SessionArchivedEvent
| ChatCreatedEvent | ChatCreatedEvent
| ChatUpdatedEvent | ChatUpdatedEvent
| ChatClosedEvent; | ChatClosedEvent
| ProjectArchivedEvent
| ProjectUnarchivedEvent
| ProjectUpdatedEvent;
type Listener = (event: SessionEvent) => void; type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>(); const listeners = new Set<Listener>();

View File

@@ -56,6 +56,8 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
const fresh: SidebarProject = { const fresh: SidebarProject = {
id: event.project.id, id: event.project.id,
name: event.project.name, name: event.project.name,
path: event.project.path,
gitea_remote: event.project.gitea_remote ?? null,
recent_sessions: [], recent_sessions: [],
total_sessions: 0, total_sessions: 0,
}; };
@@ -165,6 +167,33 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'chat_updated': case 'chat_updated':
case 'chat_closed': case 'chat_closed':
return prev; 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;
}
} }
} }

View File

@@ -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}`;
}

View File

@@ -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 { Button } from '@/components/ui/button';
import { AddProjectModal } from '@/components/AddProjectModal'; 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'; import { useSidebar } from '@/hooks/useSidebar';
export function Home() { export function Home() {
const { data } = useSidebar(); const { data } = useSidebar();
const [open, setOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [archived, setArchived] = useState<Project[] | null>(null);
const [showArchived, setShowArchived] = useState(false);
const empty = data ? data.projects.length === 0 : 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 ( return (
<div className="flex-1 flex items-center justify-center px-6"> <div className="flex-1 flex flex-col items-center px-6 py-12 overflow-y-auto">
<div className="max-w-md text-center space-y-4"> <div className="w-full max-w-md space-y-6">
{empty ? ( <div className="text-center space-y-3">
<> {empty ? (
<h1 className="text-2xl font-semibold tracking-tight">No projects yet</h1> <>
<p className="text-sm text-muted-foreground"> <h1 className="text-2xl font-semibold tracking-tight">No projects yet</h1>
Add a project from /opt to start chatting about its code. <p className="text-sm text-muted-foreground">
</p> Add a project from /opt or create a new one.
<Button onClick={() => setOpen(true)}>Add project</Button> </p>
</> </>
) : ( ) : (
<> <>
<h1 className="text-2xl font-semibold tracking-tight">BooCode</h1> <h1 className="text-2xl font-semibold tracking-tight">BooCode</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Pick a project from the sidebar. Pick a project from the sidebar, or add another.
</p> </p>
</> </>
)}
<div className="flex gap-2 justify-center pt-2">
<Button variant="outline" onClick={() => setAddOpen(true)}>Add existing project</Button>
<Button onClick={() => setCreateOpen(true)}>Create new project</Button>
</div>
</div>
{archived && archived.length > 0 && (
<div className="border-t pt-6">
<button
type="button"
onClick={() => setShowArchived(!showArchived)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
>
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Archived Projects ({archived.length})
</button>
{showArchived && (
<ul className="divide-y rounded-md border">
{archived.map((p) => (
<li key={p.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
<div className="flex-1 flex items-center gap-2 min-w-0">
<Folder className="size-3.5 opacity-40 shrink-0" />
<span className="truncate text-sm text-muted-foreground" title={p.name}>{p.name}</span>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Restore project"
title="Restore project"
onClick={() => void handleUnarchive(p.id)}
>
<RotateCcw size={14} />
</Button>
</li>
))}
</ul>
)}
</div>
)} )}
</div> </div>
<AddProjectModal open={open} onOpenChange={setOpen} onAdded={() => {}} /> <AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<CreateProjectModal open={createOpen} onOpenChange={setCreateOpen} />
</div> </div>
); );
} }

View File

@@ -9,7 +9,7 @@ services:
environment: environment:
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
volumes: volumes:
- /opt:/opt:ro - /opt:/opt:rw
depends_on: depends_on:
- boocode_db - boocode_db
networks: networks: