import type { FastifyInstance } from 'fastify'; import type { TransactionSql } from 'postgres'; import type { Sql } from '../db.js'; import type { Pane, PaneCreateRequest, PaneUpdateRequest } from '../types/api.js'; const VALID_KINDS = new Set(['chat', 'file_browser']); const MAX_PANES = 5; async function movePane( tx: TransactionSql, paneId: string, sid: string, oldPos: number, newPos: number ): Promise { if (oldPos === newPos) return; // Move target pane to a sentinel well outside the negate range [-MAX_PANES, -1] // so it never collides with negated rows during the shift steps. await tx`UPDATE session_panes SET position = -100 WHERE id = ${paneId}`; if (newPos > oldPos) { await tx`UPDATE session_panes SET position = -position WHERE session_id = ${sid} AND position > ${oldPos} AND position <= ${newPos}`; await tx`UPDATE session_panes SET position = -position - 1 WHERE session_id = ${sid} AND position < 0 AND id != ${paneId}`; } else { await tx`UPDATE session_panes SET position = -position - 2 WHERE session_id = ${sid} AND position >= ${newPos} AND position < ${oldPos}`; await tx`UPDATE session_panes SET position = -position - 1 WHERE session_id = ${sid} AND position < 0 AND id != ${paneId}`; } await tx`UPDATE session_panes SET position = ${newPos} WHERE id = ${paneId}`; } export function registerPaneRoutes(app: FastifyInstance, sql: Sql): void { // GET /api/sessions/:id/panes — list panes ordered by position ASC app.get<{ Params: { id: string } }>( '/api/sessions/:id/panes', async (req, reply) => { const sessionRows = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`; if (sessionRows.length === 0) { reply.code(404); return { error: 'session not found' }; } const panes = await sql` SELECT id, session_id, position, kind, state, created_at FROM session_panes WHERE session_id = ${req.params.id} ORDER BY position ASC `; return { panes }; } ); // POST /api/sessions/:id/panes — create a new pane app.post<{ Params: { id: string } }>( '/api/sessions/:id/panes', async (req, reply) => { const body = (req.body ?? {}) as PaneCreateRequest; const { kind, position } = body; if (!kind || !VALID_KINDS.has(kind)) { reply.code(400); return { error: 'kind must be "chat" or "file_browser"' }; } const sessionRows = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`; if (sessionRows.length === 0) { reply.code(404); return { error: 'session not found' }; } const sid = req.params.id; const state = {}; let insertError: string | null = null; const inserted = await sql.begin(async (tx) => { const countResult = await tx<{ n: number }[]>` SELECT COUNT(*)::int AS n FROM session_panes WHERE session_id = ${sid} `; const n = countResult[0]!.n; if (n >= MAX_PANES) { throw new Error('MAX_PANES_EXCEEDED'); } let insertPos: number; if (position === undefined || position === null) { insertPos = n; } else { if (position < 0 || position > n) { throw new Error('OUT_OF_BOUNDS'); } insertPos = position; } await tx`UPDATE session_panes SET position = -position - 1 WHERE session_id = ${sid} AND position >= ${insertPos}`; const [row] = await tx` INSERT INTO session_panes (session_id, position, kind, state) VALUES (${sid}, ${insertPos}, ${kind}, ${JSON.stringify(state)}::jsonb) RETURNING id, session_id, position, kind, state, created_at `; await tx`UPDATE session_panes SET position = -position WHERE session_id = ${sid} AND position < 0`; return row; }).catch((err: Error) => { insertError = err.message; return null; }); if (insertError === 'MAX_PANES_EXCEEDED') { reply.code(400); return { error: `session already has ${MAX_PANES} panes (maximum)` }; } if (insertError === 'OUT_OF_BOUNDS') { reply.code(400); return { error: `position out of bounds` }; } if (insertError) { reply.code(500); return { error: 'internal error' }; } reply.code(201); return inserted as Pane; } ); // PATCH /api/panes/:id — update state and/or position app.patch<{ Params: { id: string } }>( '/api/panes/:id', async (req, reply) => { const body = (req.body ?? {}) as PaneUpdateRequest; const { state, position } = body; if (state === undefined && position === undefined) { reply.code(400); return { error: 'must provide at least one of: state, position' }; } const paneRows = await sql` SELECT id, session_id, position, kind, state, created_at FROM session_panes WHERE id = ${req.params.id} `; if (paneRows.length === 0) { reply.code(404); return { error: 'pane not found' }; } const pane = paneRows[0]!; const sid = pane.session_id; const oldPos = pane.position; // Validate position if provided if (position !== undefined) { const countRows = await sql<{ n: number }[]>` SELECT COUNT(*)::int AS n FROM session_panes WHERE session_id = ${sid} `; const count = countRows[0]?.n ?? 0; if (position < 0 || position >= count) { reply.code(400); return { error: `position must be between 0 and ${count - 1}` }; } } // Apply position and/or state changes atomically await sql.begin(async (tx) => { if (position !== undefined && position !== oldPos) { await movePane(tx, req.params.id, sid, oldPos, position); } if (state !== undefined) { await tx` UPDATE session_panes SET state = ${JSON.stringify(state)}::jsonb WHERE id = ${req.params.id} `; } }); const [updated] = await sql` SELECT id, session_id, position, kind, state, created_at FROM session_panes WHERE id = ${req.params.id} `; return updated as Pane; } ); // DELETE /api/panes/:id — delete a pane, shift remaining down app.delete<{ Params: { id: string } }>( '/api/panes/:id', async (req, reply) => { const paneRows = await sql<{ id: string; session_id: string; position: number }[]>` SELECT id, session_id, position FROM session_panes WHERE id = ${req.params.id} `; if (paneRows.length === 0) { reply.code(404); return { error: 'pane not found' }; } const { session_id: sid, position: P } = paneRows[0]!; await sql.begin(async (tx) => { await tx`DELETE FROM session_panes WHERE id = ${req.params.id}`; await tx`UPDATE session_panes SET position = -position WHERE session_id = ${sid} AND position > ${P}`; await tx`UPDATE session_panes SET position = -position - 1 WHERE session_id = ${sid} AND position < 0`; }); reply.code(204); return null; } ); }