Skip to content

Commit 6c7c052

Browse files
committed
Release v1.1.0: modular deletion system, improved UX
1 parent fbaf064 commit 6c7c052

File tree

6 files changed

+204
-10
lines changed

6 files changed

+204
-10
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "fur-cli"
3-
version = "1.0.0"
3+
version = "1.1.0"
44
edition = "2021"
55
authors = ["Andrew Garcia"]
66
description = "Turn your AI chats into a durable, local-first diary. Save messages, attach notes, organize conversations, and stop losing context every time the model forgets you exist."

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,14 @@ All files are plain JSON or Markdown.
111111

112112
### Organize
113113

114-
| Command | Description |
115-
| ------------------------------------------ | --------------------------- |
116-
| `fur convo --tag research` | Add a tag |
117-
| `fur convo --tag "deep learning"` | Add spaced tag (normalized) |
118-
| `fur convo --clear-tags` | Remove all tags |
119-
| `fur search <query>` | Full-project search |
120-
| `fur search "deep learning, optimization"` | Multi-query search |
114+
| Command | Description |
115+
| ------------------------------------------ | ----------------------------------- |
116+
| `fur convo --tag research` | Add a tag |
117+
| `fur convo --tag "deep learning"` | Add spaced tag (normalized) |
118+
| `fur convo --clear-tags` | Remove all tags |
119+
| `fur convo --delete <id>` | Permanently delete a conversation |
120+
| `fur search <query>` | Full-project search |
121+
| `fur search "deep learning, optimization"` | Multi-query search |
121122

122123
### Export
123124

src/commands/conversation.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::path::{Path};
33
use serde_json::{Value, json};
44
use clap::Parser;
55
use chrono::{DateTime, Local, Utc};
6+
use crate::helpers::conversation::{resolve_target_thread_id,confirm_delete_primary,confirm_delete_destructive, perform_conversation_deletion};
67
use crate::renderer::table::render_table;
78
use crate::helpers::tags::parse_tag_list;
89

@@ -30,6 +31,10 @@ pub struct ThreadArgs {
3031
/// Clear all tags from conversation
3132
#[arg(long)]
3233
pub clear_tags: bool,
34+
35+
/// Delete a conversation (destructive)
36+
#[arg(long)]
37+
pub delete: bool,
3338
}
3439

3540
pub fn run_conversation(args: ThreadArgs) {
@@ -52,6 +57,10 @@ pub fn run_conversation(args: ThreadArgs) {
5257
return handle_rename_thread(&mut index, fur_dir, &args);
5358
}
5459

60+
if args.delete {
61+
return handle_delete_thread(&mut index, fur_dir, &args);
62+
}
63+
5564
if args.view || args.id.is_none() {
5665
return handle_view_threads(&index, fur_dir, &args);
5766
}
@@ -121,6 +130,40 @@ fn handle_rename_thread(
121130
}
122131

123132

133+
fn handle_delete_thread(
134+
index: &mut Value,
135+
fur_dir: &Path,
136+
args: &ThreadArgs,
137+
) {
138+
let target_tid = match resolve_target_thread_id(index, args) {
139+
Some(tid) => tid,
140+
None => return,
141+
};
142+
143+
// extract all thread IDs for later index update
144+
let empty_vec: Vec<Value> = Vec::new();
145+
let threads: Vec<String> = index["threads"]
146+
.as_array()
147+
.unwrap_or(&empty_vec)
148+
.iter()
149+
.filter_map(|v| v.as_str().map(|s| s.to_string()))
150+
.collect();
151+
152+
if !confirm_delete_primary() {
153+
println!("❌ Deletion aborted.");
154+
return;
155+
}
156+
157+
if !confirm_delete_destructive() {
158+
println!("❌ Deletion aborted.");
159+
return;
160+
}
161+
162+
perform_conversation_deletion(index, fur_dir, &target_tid, &threads);
163+
}
164+
165+
166+
124167
fn handle_view_threads(
125168
index: &Value,
126169
fur_dir: &Path,

src/helpers/conversation.rs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
use std::fs;
2+
use std::path::{Path};
3+
use serde_json::{Value, json};
4+
use std::io::{self, Write};
5+
use crate::commands::conversation::ThreadArgs;
6+
7+
pub fn resolve_target_thread_id(
8+
index: &Value,
9+
args: &ThreadArgs,
10+
) -> Option<String> {
11+
let empty_vec: Vec<Value> = Vec::new();
12+
let threads: Vec<String> = index["threads"]
13+
.as_array()
14+
.unwrap_or(&empty_vec)
15+
.iter()
16+
.filter_map(|t| t.as_str().map(|s| s.to_string()))
17+
.collect();
18+
19+
// If ID prefix provided
20+
if let Some(prefix) = &args.id {
21+
let matches: Vec<&String> = threads
22+
.iter()
23+
.filter(|tid| tid.starts_with(prefix))
24+
.collect();
25+
26+
return match matches.as_slice() {
27+
[] => {
28+
eprintln!("❌ No conversation matches '{}'", prefix);
29+
None
30+
}
31+
[single] => Some((*single).clone()),
32+
_ => {
33+
eprintln!("❌ Ambiguous prefix '{}': {:?}", prefix, matches);
34+
None
35+
}
36+
};
37+
}
38+
39+
// Otherwise use active thread
40+
let active = index["active_thread"].as_str().unwrap_or("").to_string();
41+
if active.is_empty() {
42+
eprintln!("❌ No active conversation to delete.");
43+
return None;
44+
}
45+
46+
Some(active)
47+
}
48+
49+
pub fn confirm_delete_primary() -> bool {
50+
51+
println!("Are you sure you want to delete this conversation? (y/N)");
52+
print!("> ");
53+
io::stdout().flush().unwrap();
54+
55+
let mut input = String::new();
56+
io::stdin().read_line(&mut input).unwrap();
57+
58+
input.trim().to_lowercase() == "y"
59+
}
60+
61+
pub fn confirm_delete_destructive() -> bool {
62+
63+
println!();
64+
println!(
65+
"\x1b[31m⚠️ Reminder: deleting a conversation is a destructive action.\n\
66+
It cannot be reversed unless the project is version-controlled (git).\x1b[0m"
67+
);
68+
println!();
69+
println!("Type DELETE to confirm:");
70+
print!("> ");
71+
io::stdout().flush().unwrap();
72+
73+
let mut input = String::new();
74+
io::stdin().read_line(&mut input).unwrap();
75+
76+
input.trim() == "DELETE"
77+
}
78+
79+
pub fn perform_conversation_deletion(
80+
index: &mut Value,
81+
fur_dir: &Path,
82+
target_tid: &str,
83+
threads: &[String],
84+
) {
85+
let convo_path = fur_dir.join("threads").join(format!("{}.json", target_tid));
86+
87+
// Load convo to extract message IDs + title
88+
let convo_content = fs::read_to_string(&convo_path)
89+
.expect("Failed to load conversation JSON.");
90+
let convo: Value = serde_json::from_str(&convo_content).unwrap();
91+
92+
let title = convo["title"].as_str().unwrap_or("Untitled");
93+
let msg_ids: Vec<String> = convo["messages"]
94+
.as_array()
95+
.unwrap_or(&vec![])
96+
.iter()
97+
.filter_map(|v| v.as_str().map(|s| s.to_string()))
98+
.collect();
99+
100+
println!(
101+
"🗑️ Deleting conversation {} \"{}\"...",
102+
&target_tid[..8],
103+
title
104+
);
105+
106+
// 1. Delete conversation JSON
107+
let _ = fs::remove_file(&convo_path);
108+
109+
// 2. Delete message files and markdown attachments
110+
for mid in msg_ids {
111+
let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
112+
113+
if let Ok(content) = fs::read_to_string(&msg_path) {
114+
if let Ok(msg_json) = serde_json::from_str::<Value>(&content) {
115+
if let Some(md_raw) = msg_json["markdown"].as_str() {
116+
let md_path = Path::new(md_raw);
117+
if md_path.is_absolute() {
118+
let _ = fs::remove_file(md_path);
119+
} else {
120+
let _ = fs::remove_file(Path::new(".").join(md_raw));
121+
}
122+
}
123+
}
124+
}
125+
126+
let _ = fs::remove_file(&msg_path);
127+
}
128+
129+
// 3. Update index.json (remove thread)
130+
let new_threads: Vec<String> = threads
131+
.iter()
132+
.filter(|tid| tid.as_str() != target_tid)
133+
.cloned()
134+
.collect();
135+
136+
index["threads"] = json!(new_threads);
137+
138+
// 4. Clear active thread if it matches deleted
139+
if index["active_thread"].as_str() == Some(target_tid) {
140+
index["active_thread"] = Value::Null;
141+
index["current_message"] = Value::Null;
142+
}
143+
144+
// 5. Save index.json
145+
let index_path = fur_dir.join("index.json");
146+
fs::write(&index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
147+
148+
println!("✔️ Conversation deleted successfully.");
149+
}

src/helpers/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
pub mod tags;
2-
pub mod search;
2+
pub mod search;
3+
pub mod conversation;

0 commit comments

Comments
 (0)