Compare commits

..
Author SHA1 Message Date
Richard Zink 5d5c0f8124 Release v202611-00037 2026-11-15 13:53:13 +01:00
Richard ZinkandClaude Sonnet 5.5 f77b101847 Ask about the chat's own folder when deleting a chat
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 13:53:10 +01:00
10 changed files with 192 additions and 16 deletions

No files matched your search

+8
View File
@@ -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.
## [v202611-00037] – 2026-11-15
### 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
+1 -1
View File
@@ -11,7 +11,7 @@
</p>
<p align="center">
<img alt="Version v202611-00036" src="https://img.shields.io/badge/version-v202611--00036-d69e2e?style=flat-square">
<img alt="Version v202611-00037" src="https://img.shields.io/badge/version-v202611--00037-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
+92 -3
View File
@@ -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<User>, State(app): State<AppSt
Ok(Json(json!({"ok": true})))
}
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> 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<PathBuf>, Vec<String>) {
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<PathBuf> = 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<User>, State(app): State<AppState>, Path(id): Path<String>) -> 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<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<DeleteQ>) -> 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<User>, State(app): State<AppS
tracing::warn!("image cleanup: {e:#}");
}
});
Ok(Json(json!({"ok": true})))
let mut folder_error = None;
let mut folder_deleted = false;
if let Some(dir) = folder {
let shown = dir.display().to_string();
match tokio::task::spawn_blocking(move || std::fs::remove_dir_all(&dir)).await {
Ok(Ok(())) => {
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,
+10
View File
@@ -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<Vec<(String, String)>> {
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::<std::result::Result<_, _>>()?)
}
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();
+25 -4
View File
@@ -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");
};
+36 -8
View File
@@ -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<boolean> {
});
}
/** Several actions to choose from (plus Cancel): resolves to the chosen value, or null. */
export function choiceDialog(opts: ChoiceOpts): Promise<string | null> {
return new Promise((resolve) => {
dialogs = [...dialogs, { kind: "choice", id: ++seq, resolve, ...opts }];
emit();
});
}
export function promptDialog(opts: PromptOpts | string): Promise<string | null> {
const o = typeof opts === "string" ? { message: opts } : opts;
return new Promise((resolve) => {
@@ -108,14 +123,27 @@ function DialogView({ d }: { d: Dialog }) {
style={{ width: "100%" }}
/>
)}
<div className="row dialog-actions">
<button type="button" onClick={() => close(d, cancelValue)}>
{t("common.cancel")}
</button>
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
{d.confirmLabel ?? t("common.ok")}
</button>
</div>
{d.kind === "choice" ? (
<div className="row dialog-actions dialog-choices">
<button type="button" onClick={() => close(d, null)}>
{t("common.cancel")}
</button>
{d.choices.map((c, i) => (
<button key={c.value} ref={i === d.choices.length - 1 ? ok : undefined} type="button" className={c.danger ? "danger-solid" : i === d.choices.length - 1 ? "primary" : undefined} onClick={() => close(d, c.value)}>
{c.label}
</button>
))}
</div>
) : (
<div className="row dialog-actions">
<button type="button" onClick={() => close(d, cancelValue)}>
{t("common.cancel")}
</button>
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
{d.confirmLabel ?? t("common.ok")}
</button>
</div>
)}
</form>
</div>
);
+7
View File
@@ -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",
};
+7
View File
@@ -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<keyof typeof en, string>;
+2
View File
@@ -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; }
+4
View File
@@ -3,6 +3,10 @@
* under "unreleased"; scripts/release.sh renames the key to the release version.
*/
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00037": {
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 …)"],