Ask about the chat's own folder when deleting a chat
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
12a3198e77
commit
f77b101847
9 files changed
+191
-15
No files matched your search
+92
-3
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user