From f77b101847b79ed901fabeab55ec5ebebf5da364 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sun, 15 Nov 2026 13:53:10 +0100 Subject: [PATCH] Ask about the chat's own folder when deleting a chat Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 8 +++ server/src/api.rs | 95 +++++++++++++++++++++++++++++++-- server/src/store.rs | 10 ++++ web/src/components/ChatView.tsx | 29 ++++++++-- web/src/dialogs.tsx | 44 ++++++++++++--- web/src/i18n/de.ts | 7 +++ web/src/i18n/en.ts | 7 +++ web/src/styles.css | 2 + web/src/whatsnew.ts | 4 ++ 9 files changed, 191 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b47a4fc..2da05b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **Deleting a chat asks about its folder**: if the chat has a folder of its own (inside your folders, + not shared with any other chat, no project folder), you can delete it with the chat – with the + number of files, size and a warning for git repositories. The server checks again before deleting. + ## [v202611-00036] – 2026-11-15 ### Added diff --git a/server/src/api.rs b/server/src/api.rs index b4a75e4..ff7860b 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -86,6 +86,7 @@ pub fn router(app: AppState) -> Router { .route("/sessions/{id}/search", get(search_in_session)) .route("/sessions/{id}/media", get(session_media)) .route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session)) + .route("/sessions/{id}/folder", get(session_folder)) .route("/sessions/{id}/prompt", post(prompt)) .route("/sessions/{id}/abort", post(abort)) .route("/sessions/{id}/compact", post(compact)) @@ -1056,8 +1057,83 @@ async fn put_file_tabs(Extension(user): Extension, State(app): State, State(app): State, Path(id): Path) -> ApiResult { - writable(&app, &user, &id)?; +/// The folder a chat owns: inside the owner's folders (never one of them), not a link, and no +/// other chat works in it, below it or above it (then it belongs to a project, not to this chat). +/// Returns the folder and the titles of chats that prevent it. +fn own_folder(app: &App, session: &Session) -> (Option, Vec) { + let Ok(dir) = std::fs::canonicalize(&session.cwd) else { return (None, vec![]) }; + if std::fs::symlink_metadata(&session.cwd).map(|m| m.file_type().is_symlink()).unwrap_or(true) || !dir.is_dir() { + return (None, vec![]); + } + let roots: Vec = app.user_roots(session.owner_id.as_deref()).iter().filter_map(|r| std::fs::canonicalize(r).ok()).collect(); + if !roots.iter().any(|r| dir.starts_with(r) && dir != *r) { + return (None, vec![]); + } + let mut blockers = Vec::new(); + for (title, cwd) in app.store.other_session_cwds(&session.id).unwrap_or_default() { + let Ok(other) = std::fs::canonicalize(&cwd) else { continue }; + if other.starts_with(&dir) || dir.starts_with(&other) { + blockers.push(if title.is_empty() { "(untitled)".to_string() } else { title }); + } + } + if blockers.is_empty() { (Some(dir), blockers) } else { (None, blockers) } +} + +/// Facts for the delete dialog: does the chat have a folder of its own, and what is in it? +async fn session_folder(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + let session = writable(&app, &user, &id)?; + let (dir, blockers) = own_folder(&app, &session); + let Some(dir) = dir else { return Ok(Json(json!({"own": false, "shared_with": blockers}))) }; + let (files, bytes, git, truncated) = tokio::task::spawn_blocking({ + let dir = dir.clone(); + move || { + let (mut files, mut bytes, mut seen) = (0u64, 0u64, 0u64); + let mut stack = vec![dir.clone()]; + let mut truncated = false; + while let Some(d) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&d) else { continue }; + for e in rd.flatten() { + // Git's own files are not "the chat's files". + if e.file_name() == ".git" { + continue; + } + seen += 1; + if seen > 50_000 { + truncated = true; + stack.clear(); + break; + } + let Ok(m) = e.metadata() else { continue }; + if m.is_dir() && !e.file_type().map(|t| t.is_symlink()).unwrap_or(true) { + stack.push(e.path()); + } else if m.is_file() { + files += 1; + bytes += m.len(); + } + } + } + (files, bytes, dir.join(".git").exists(), truncated) + } + }) + .await + .unwrap_or((0, 0, false, false)); + Ok(Json(json!({"own": true, "path": dir, "files": files, "bytes": bytes, "git": git, "truncated": truncated}))) +} + +#[derive(Deserialize)] +struct DeleteQ { + /// Also delete the chat's own folder (only if it really is one – checked again here). + #[serde(default)] + delete_folder: bool, +} + +async fn delete_session(Extension(user): Extension, State(app): State, Path(id): Path, Query(q): Query) -> ApiResult { + let session = writable(&app, &user, &id)?; + // Decide about the folder while the chat and all the others still exist. + let folder = if q.delete_folder { own_folder(&app, &session).0 } else { None }; + if q.delete_folder && folder.is_none() { + return Err(ApiError(StatusCode::CONFLICT, "this chat has no folder of its own (other chats use it, or it is a project folder)".into())); + } // Notify before deleting: visibility is checked against the stored session. app.emit(Event::SessionDeleted { session_id: id.clone() }); runner::abort(&app, &id); @@ -1071,7 +1147,20 @@ async fn delete_session(Extension(user): Extension, State(app): State { + folder_deleted = true; + app.audit(Some(&user), "session.delete_folder", &shown, ""); + } + Ok(Err(e)) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")), + Err(e) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")), + } + } + Ok(Json(json!({"ok": true, "folder_deleted": folder_deleted, "folder_error": folder_error}))) } /// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes, diff --git a/server/src/store.rs b/server/src/store.rs index c63c4cd..44d8b92 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -409,6 +409,16 @@ impl Store { Ok(()) } + /// (title, cwd) of all other chats (subagent sessions of this chat excluded). + pub fn other_session_cwds(&self, id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT COALESCE(json_extract(data, '$.title'), ''), COALESCE(json_extract(data, '$.cwd'), '') FROM sessions WHERE id != ?1 AND COALESCE(parent_id, '') != ?1", + )?; + let rows = stmt.query_map([id], |r| Ok((r.get(0)?, r.get(1)?)))?; + Ok(rows.collect::>()?) + } + pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> { // Images go to the blob table (encoded before taking the lock). let mut m = m.clone(); diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 0ab9582..1f34d3d 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -1,6 +1,6 @@ import { takePrefill } from "../share"; import { Icon } from "./Icon"; -import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure"; +import { FileChip, FileFigure, fileType, imageSize, imageSrc, formatSize } from "./FileFigure"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; import { del, get, patch, post, put } from "../api"; import { useFetch, useModels } from "../hooks"; @@ -24,7 +24,7 @@ import { UsageOverlay } from "./UsageOverlay"; import { RewindDialog } from "./RewindDialog"; import { ShellsChip } from "./ShellsPanel"; import { TodoList, ToolCard } from "./ToolCard"; -import { confirmDialog, promptDialog, toast } from "../dialogs"; +import { choiceDialog, confirmDialog, promptDialog, toast } from "../dialogs"; import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs"; import { ColorSwatches, IconPicker } from "./ColorSwatches"; import { ProjectPermissions } from "./ProjectPermissions"; @@ -757,8 +757,29 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string }; const remove = async () => { - if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; - await del(`/sessions/${sessionId}`); + // Does the chat have a folder of its own? Then ask whether it goes too. + const folder = session.parent_id ? undefined : await get<{ own: boolean; path?: string; files?: number; bytes?: number; git?: boolean; truncated?: boolean }>(`/sessions/${sessionId}/folder`).catch(() => undefined); + let withFolder = false; + if (folder?.own) { + const size = `${folder.truncated ? "> " : ""}${t("chat.folderFiles", { n: folder.files ?? 0 })}, ${formatSize(folder.bytes ?? 0)}`; + const choice = await choiceDialog({ + title: t("chat.deleteTitle"), + message: `${t("chat.deleteWithFolder", { path: folder.path ?? "", size })}${folder.git ? `\n\n${t("chat.folderHasGit")}` : ""}`, + choices: [ + { value: "chat", label: t("chat.deleteOnlyChat") }, + { value: "both", label: t("chat.deleteChatAndFolder"), danger: true }, + ], + }); + if (!choice) return; + withFolder = choice === "both"; + } else if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; + try { + const r = await del<{ folder_deleted?: boolean; folder_error?: string }>(`/sessions/${sessionId}${withFolder ? "?delete_folder=true" : ""}`); + if (r?.folder_error) toast(r.folder_error); + else if (r?.folder_deleted) toast(t("chat.folderDeleted"), "success"); + } catch (e: any) { + return void toast(e.message); + } navigate(session.parent_id ? `/s/${session.parent_id}` : "/new"); }; diff --git a/web/src/dialogs.tsx b/web/src/dialogs.tsx index c4db19d..0f47336 100644 --- a/web/src/dialogs.tsx +++ b/web/src/dialogs.tsx @@ -20,8 +20,15 @@ interface PromptOpts { confirmLabel?: string; } +interface ChoiceOpts { + title?: string; + message: string; + choices: { value: string; label: string; danger?: boolean }[]; +} + type Dialog = | ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts) + | ({ kind: "choice"; id: number; resolve: (v: string | null) => void } & ChoiceOpts) | ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts); interface Toast { @@ -45,6 +52,14 @@ export function confirmDialog(opts: ConfirmOpts | string): Promise { }); } +/** Several actions to choose from (plus Cancel): resolves to the chosen value, or null. */ +export function choiceDialog(opts: ChoiceOpts): Promise { + return new Promise((resolve) => { + dialogs = [...dialogs, { kind: "choice", id: ++seq, resolve, ...opts }]; + emit(); + }); +} + export function promptDialog(opts: PromptOpts | string): Promise { const o = typeof opts === "string" ? { message: opts } : opts; return new Promise((resolve) => { @@ -108,14 +123,27 @@ function DialogView({ d }: { d: Dialog }) { style={{ width: "100%" }} /> )} -
- - -
+ {d.kind === "choice" ? ( +
+ + {d.choices.map((c, i) => ( + + ))} +
+ ) : ( +
+ + +
+ )} ); diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 6959175..4e1e830 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -1161,4 +1161,11 @@ export const de: Messages = { "ssh.copyFailed": "Kopieren nicht möglich – Text von Hand markieren", "ssh.added": "Schlüssel hinzugefügt", "ssh.deleteConfirm": "Schlüssel \"{name}\" löschen? Server, die ihm vertrauen, akzeptieren dich von hier aus nicht mehr.", + "chat.deleteTitle": "Chat löschen", + "chat.deleteWithFolder": "Dieser Chat hat einen eigenen Ordner:\n{path}\n({size})\n\nSoll der Ordner mit dem Chat gelöscht werden?", + "chat.deleteOnlyChat": "Nur Chat löschen", + "chat.deleteChatAndFolder": "Chat und Ordner löschen", + "chat.folderFiles": "{n} Dateien", + "chat.folderHasGit": "Der Ordner enthält ein Git-Repository – nicht gepushte Commits und Branches gehen verloren.", + "chat.folderDeleted": "Chat und Ordner gelöscht", }; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 32a2c8d..78ed39f 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1160,6 +1160,13 @@ export const en = { "ssh.copyFailed": "Could not copy – select the text manually", "ssh.added": "Key added", "ssh.deleteConfirm": "Delete the key \"{name}\"? Servers that trust it will no longer accept you from here.", + "chat.deleteTitle": "Delete chat", + "chat.deleteWithFolder": "This chat has a folder of its own:\n{path}\n({size})\n\nDelete the folder with the chat?", + "chat.deleteOnlyChat": "Delete chat only", + "chat.deleteChatAndFolder": "Delete chat and folder", + "chat.folderFiles": "{n} files", + "chat.folderHasGit": "The folder contains a git repository – commits and branches that were not pushed are lost.", + "chat.folderDeleted": "Chat and folder deleted", }; export type Messages = Record; diff --git a/web/src/styles.css b/web/src/styles.css index d7dd279..f7fbb80 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -5026,3 +5026,5 @@ td.fold-indent { .ssh-key { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; } .ssh-key .row { align-items: center; flex-wrap: wrap; } .ssh-key input.mono { width: 100%; font-size: 12px; } +.dialog-message { overflow-wrap: anywhere; } +.dialog-choices { flex-wrap: wrap; } diff --git a/web/src/whatsnew.ts b/web/src/whatsnew.ts index 4d7dc90..45b8600 100644 --- a/web/src/whatsnew.ts +++ b/web/src/whatsnew.ts @@ -3,6 +3,10 @@ * under "unreleased"; scripts/release.sh renames the key to the release version. */ export const WHATS_NEW: Record = { + unreleased: { + en: ["Deleting a chat now asks whether to delete its own folder too"], + de: ["Beim Löschen eines Chats wirst du gefragt, ob sein eigener Ordner mitgelöscht werden soll"], + }, "202611-00036": { en: ["Claude Code can now use all CodeCraft tools (image generation, 3D, browser, scheduling …)"], de: ["Claude Code kann jetzt alle CodeCraft-Tools nutzen (Bildgenerierung, 3D, Browser, Zeitpläne …)"],