Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
maybeStartPreview,
ensureRunner,
loadRoomIndex,
deleteRoom,
type Room,
type SelectedElement,
} from "./rooms.js";
Expand Down Expand Up @@ -311,6 +312,27 @@ fastify.get<{ Params: { id: string } }>("/rooms/:id/export/estado", async (req,
};
});

/**
* Borrar una sala: su proyecto, su contenedor y su historial.
*
* Es DELETE y no un POST a algo: destruye un recurso entero, y el método dice
* eso solo. Va bajo `/rooms` por lo mismo que el export (el proxy del preview
* no toca ese prefijo).
*
* No hay dueño que comprobar: la sala es de quien tenga el link, y sin cuentas
* no existe forma de saber quién la creó. Lo que protege de un borrado por
* accidente es la confirmación de la interfaz, no este endpoint.
*/
fastify.delete<{ Params: { id: string } }>("/rooms/:id", async (req, reply) => {
const borrada = await deleteRoom(req.params.id);
if (!borrada) return reply.code(404).send({ error: "sala no encontrada" });

// A quien esté dentro se le avisa: su sala acaba de dejar de existir y el
// preview que tiene enfrente ya no responde.
io.to(req.params.id).emit("room:deleted", {});
return { ok: true };
});

// Info de una sala (para saber la URL del preview al entrar).
fastify.get<{ Params: { id: string } }>("/rooms/:id", async (req, reply) => {
const room = getRoom(req.params.id);
Expand Down
38 changes: 37 additions & 1 deletion server/src/rooms.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { readFile, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { createWorkspace, type Workspace } from "./engine/workspace.js";
import { startPreview, detectLaunch, type Preview } from "./engine/preview.js";
Expand Down Expand Up @@ -347,6 +347,42 @@ export async function stopAllPreviews(): Promise<void> {
);
}

/**
* Borra una sala del todo: su proyecto, su contenedor y su rastro en la BD.
*
* Es irreversible y es PARA TODOS: la sala es de quien tenga el link, no de
* quien aprieta el botón. Por eso la confirmación vive en la interfaz, y por eso
* la Sala ofrece bajarse el .zip antes.
*
* El orden importa. Primero se apaga lo que está corriendo (el dev server tiene
* el workspace abierto y el contenedor lo tiene montado); borrar el directorio
* con ellos vivos deja procesos escribiendo en archivos que ya no existen y, en
* el caso del contenedor, un montaje colgado. Al final se saca de memoria, para
* que nadie la resucite a media limpieza.
*/
export async function deleteRoom(id: string): Promise<boolean> {
const room = rooms.get(id) ?? (await wakeRoom(id));
if (!room) return false;

// Fuera de memoria antes de tocar nada: si alguien pide entrar mientras se
// borra, se encuentra una sala que ya no existe en vez de una a medio morir.
rooms.delete(id);

await room.preview?.stop().catch(() => {});
if (room.container) await stopContainer(id).catch(() => {});

// El workspace y lo que cuelga de él: los adjuntos y los turnos viven al lado,
// como hermanos con el id de la sala por prefijo (workspaces/<id>.adjuntos).
const dir = room.workspace.dir;
await rm(dir, { recursive: true, force: true }).catch(() => {});
await rm(`${dir}.adjuntos`, { recursive: true, force: true }).catch(() => {});
await rm(`${dir}.turns.json`, { force: true }).catch(() => {});
await rm(`${dir}.bookmarks.json`, { force: true }).catch(() => {});

await (await getStorage()).deleteRoom(id);
return true;
}

export function addMember(room: Room, socketId: string, name: string): Member {
const member: Member = {
socketId,
Expand Down
13 changes: 13 additions & 0 deletions server/src/storage/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ export class SqliteStorage implements Storage {
this.db.prepare(`UPDATE rooms SET last_active_at = ? WHERE id = ?`).run(Date.now(), id);
}

/**
* Los mensajes y los historiales se van solos: sus tablas declaran
* `ON DELETE CASCADE` contra `rooms(id)` y el PRAGMA de claves foráneas está
* activo desde el constructor. Aun así se borran a mano primero, porque si
* ese PRAGMA se cayera algún día esto dejaría filas huérfanas en silencio y
* la BD crecería sin que nadie lo notara.
*/
async deleteRoom(id: string): Promise<void> {
this.db.prepare(`DELETE FROM agent_histories WHERE room_id = ?`).run(id);
this.db.prepare(`DELETE FROM messages WHERE room_id = ?`).run(id);
this.db.prepare(`DELETE FROM rooms WHERE id = ?`).run(id);
}

async appendMessage(m: StoredMessage): Promise<void> {
this.db
.prepare(
Expand Down
7 changes: 7 additions & 0 deletions server/src/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export interface Storage {
listRooms(): Promise<StoredRoom[]>;
/** Marca actividad reciente (para saber qué salas siguen vivas). */
touchRoom(id: string): Promise<void>;
/**
* Borra la sala y todo lo suyo: mensajes e historiales de agentes.
*
* Solo la parte de base de datos. El workspace en disco y el contenedor los
* quita quien llama, porque no viven aquí.
*/
deleteRoom(id: string): Promise<void>;

appendMessage(msg: StoredMessage): Promise<void>;
/** Los mensajes de una sala, en orden. */
Expand Down
25 changes: 22 additions & 3 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { BackCanvas, type Endpoint } from "./BackCanvas.js";
import { KeyPanel, loadStoredCredencial, type Credencial } from "./KeyPanel.js";
import { useTextos } from "./i18n.js";
import { MenuSalas } from "./MenuSalas.js";
import { recordarSala } from "./historial-salas.js";
import { recordarSala, olvidarSala } from "./historial-salas.js";
import {
prepararImagen,
imagenesDe,
Expand Down Expand Up @@ -96,8 +96,18 @@ export function App() {
);
}

// Pantalla 3: la sala.
return <Sala roomId={roomId} name={name || "anónimo"} />;
/**
* Pantalla 3: la sala.
*
* La `key` es lo que hace que al cambiar de sala se empiece de cero. Sin
* ella React ve el mismo componente en el mismo sitio, reusa la instancia y
* conserva su estado: los mensajes, los agentes y el preview de la sala
* ANTERIOR. Al entrar a una sala con historial no se notaba, porque el
* `joined` llegaba con mensajes y pisaba lo viejo; al crear una sala nueva sí,
* porque llega vacío y nada sobrescribe. Aparecías en una sala recién creada
* leyendo la conversación de otra.
*/
return <Sala key={roomId} roomId={roomId} name={name || "anónimo"} />;
}

// ── Pantalla: crear / entrar a sala ────────────────────────────────────────
Expand Down Expand Up @@ -419,6 +429,15 @@ function Sala({ roomId, name }: { roomId: string; name: string }) {

socket.on("error:join", ({ message }: { message: string }) => alert(message));

// Alguien borró esta sala mientras estabas dentro. Sin esto te quedabas
// frente a un preview que ya no responde y un chat que no manda nada,
// sin saber por qué.
socket.on("room:deleted", () => {
olvidarSala(roomId);
alert(t.salaBorrada);
window.location.hash = "#/";
});

// Solo a mí: mi key faltaba o el server la rechazó. Abre el panel.
socket.on("error:key", ({ message }: { message: string }) => {
setKeyError(message);
Expand Down
62 changes: 54 additions & 8 deletions web/src/MenuSalas.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { salasVisitadas, olvidarSala, type SalaVisitada } from "./historial-salas.js";
import { createRoom } from "./socket.js";
import { createRoom, borrarSala } from "./socket.js";
import { useTextos } from "./i18n.js";

/**
Expand All @@ -17,8 +17,51 @@ export function MenuSalas({ actual }: { actual?: string }) {
const [abierto, setAbierto] = useState(false);
const [salas, setSalas] = useState<SalaVisitada[]>([]);
const [creando, setCreando] = useState(false);
/** El id de la sala que se está borrando ahora mismo, o null. */
const [borrando, setBorrando] = useState<string | null>(null);
/** La que se acaba de borrar, para avisarlo. Se limpia sola. */
const [borrada, setBorrada] = useState<string | null>(null);
const cajaRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!borrada) return;
const id = setTimeout(() => setBorrada(null), 4000);
return () => clearTimeout(id);
}, [borrada]);

/**
* Borrar una sala de verdad, no solo quitarla de esta lista.
*
* Se pregunta antes porque es irreversible y NO es personal: la sala es de
* quien tenga el link, así que borrarla se lleva el trabajo de los compas que
* estén ahí. El nombre va en la pregunta para que se vea cuál se va a ir: en
* una lista de salas que se llaman parecido, "¿seguro?" a secas no dice nada.
*
* Si el server falla, la sala NO se quita de la lista: seguiría existiendo y
* borrarla del menú solo escondería algo que sigue ocupando disco.
*/
const borrar = async (id: string) => {
if (!confirm(t.confirmarBorrarSala(id))) return;
setBorrando(id);
try {
await borrarSala(id);
olvidarSala(id);
setSalas(salasVisitadas());
if (id === actual) {
// Borraste la sala donde estabas: ahí ya no hay nada que ver, y como el
// menú se va contigo, el aviso de abajo no se alcanzaría a leer.
alert(t.salaBorradaOk(id));
window.location.hash = "#/";
} else {
setBorrada(id);
}
} catch (e) {
alert(t.noSePudoBorrar + String(e));
} finally {
setBorrando(null);
}
};

const crearOtra = async () => {
setCreando(true);
try {
Expand Down Expand Up @@ -75,6 +118,11 @@ export function MenuSalas({ actual }: { actual?: string }) {
<div className="menu-panel">
<div className="menu-cab">{t.tusSalas}</div>

{/* Que la fila desaparezca no dice si se borró la sala o solo se quitó
del menú. Este aviso lo confirma, y se va solo: es una noticia de
paso, no un estado en el que la interfaz se quede. */}
{borrada && <p className="menu-borrada">{t.salaBorradaOk(borrada)}</p>}

{otras.length === 0 ? (
<p className="menu-vacio">{t.sinOtrasSalas}</p>
) : (
Expand All @@ -87,14 +135,12 @@ export function MenuSalas({ actual }: { actual?: string }) {
</a>
<button
className="menu-olvidar"
title={t.quitarDeLaLista}
aria-label={`${t.quitarDeLaLista}: ${s.id}`}
onClick={() => {
olvidarSala(s.id);
setSalas(salasVisitadas());
}}
title={t.borrarSala}
aria-label={`${t.borrarSala}: ${s.id}`}
disabled={borrando === s.id}
onClick={() => borrar(s.id)}
>
×
{borrando === s.id ? "…" : "×"}
</button>
</li>
))}
Expand Down
14 changes: 14 additions & 0 deletions web/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ const TEXTOS = {
sinOtrasSalas: "Todavía no has entrado a otra sala.",
crearOtraSala: "Crear otra sala",
quitarDeLaLista: "Quitar de la lista",
borrarSala: "Borrar la sala",
// Se nombra la sala y se dice que es para todos: borrarla se lleva el
// trabajo de quien estuviera ahí, no solo tu acceso.
confirmarBorrarSala: (id: string) =>
`¿Borrar la sala ${id}? Se va el proyecto y el chat, para ti y para todos los que entren con el link. No se puede deshacer.`,
noSePudoBorrar: "no se pudo borrar la sala: ",
salaBorrada: "Alguien borró esta sala. El proyecto y el chat ya no existen.",
salaBorradaOk: (id: string) => `Se borró ${id}.`,

// Historial
historial: "Historial",
Expand Down Expand Up @@ -246,6 +254,12 @@ const TEXTOS = {
sinOtrasSalas: "You haven't joined another room yet.",
crearOtraSala: "Create another room",
quitarDeLaLista: "Remove from the list",
borrarSala: "Delete the room",
confirmarBorrarSala: (id: string) =>
`Delete room ${id}? The project and the chat are gone, for you and for anyone with the link. This can't be undone.`,
noSePudoBorrar: "couldn't delete the room: ",
salaBorrada: "Someone deleted this room. The project and the chat are gone.",
salaBorradaOk: (id: string) => `${id} deleted.`,

// Historial
historial: "History",
Expand Down
11 changes: 11 additions & 0 deletions web/src/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ export async function createRoom(): Promise<string> {
return data.id;
}

/**
* Borra la sala del servidor: proyecto, contenedor e historial.
*
* Irreversible, y para todos los que estén en ella. Quien llame a esto tiene
* que haber preguntado antes.
*/
export async function borrarSala(id: string): Promise<void> {
const res = await fetch(`${SERVER_URL}/rooms/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("no se pudo borrar la sala");
}

// ── Socket ──────────────────────────────────────────────────────────────────

export function connectSocket(): Socket {
Expand Down
16 changes: 16 additions & 0 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,22 @@ body::after {
padding: 4px 8px 10px;
line-height: 1.5;
}
/* Confirma que la sala se borró de verdad, no solo de esta lista. Aparece
suave para que se note que es nuevo, y se quita solo a los segundos. */
.menu-borrada {
font-size: 11.5px;
color: var(--dim);
padding: 2px 8px 8px;
animation: aparecer 0.2s ease-out;
}
@keyframes aparecer {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.menu-lista {
list-style: none;
display: flex;
Expand Down
Loading