feat(tui): add file save/close commands and session save handling
- Updated command specs: added `w`, `write`, `wq`, `x`, and `session save` with proper descriptions. - Introduced `SaveStatus` enum and helper methods for path display and buffer labeling. - Implemented `update_paths` in `Workspace` to keep title in sync with file paths. - Added comprehensive `save_active_code_buffer` and enhanced `close_active_code_buffer` logic, including force‑close via `!`. - Parsed force flag from commands (e.g., `:q!`) and routed commands to new save/close workflows. - Integrated session save subcommand with optional description generation.
This commit is contained in:
@@ -74,6 +74,13 @@ enum SlashOutcome {
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SaveStatus {
|
||||
Saved,
|
||||
NoChanges,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ModelSelectorItem {
|
||||
kind: ModelSelectorItemKind,
|
||||
@@ -2895,6 +2902,170 @@ impl ChatApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn display_label_for_absolute(&self, absolute: &Path) -> String {
|
||||
let root = self.file_tree().root();
|
||||
if let Some(relative) = diff_paths(absolute, root) {
|
||||
let rel_str = relative.to_string_lossy().into_owned();
|
||||
if rel_str.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
rel_str
|
||||
}
|
||||
} else {
|
||||
absolute.to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn buffer_label(&self, display: Option<&str>, absolute: Option<&Path>) -> String {
|
||||
if let Some(display) = display {
|
||||
let trimmed = display.trim();
|
||||
if trimmed.is_empty() {
|
||||
"untitled buffer".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
} else if let Some(absolute) = absolute {
|
||||
self.display_label_for_absolute(absolute)
|
||||
} else {
|
||||
"untitled buffer".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_active_code_buffer(
|
||||
&mut self,
|
||||
path_arg: Option<String>,
|
||||
force: bool,
|
||||
) -> Result<SaveStatus> {
|
||||
let pane_snapshot = if let Some(pane) = self.code_workspace.active_pane() {
|
||||
(
|
||||
pane.lines.join("\n"),
|
||||
pane.absolute_path().map(Path::to_path_buf),
|
||||
pane.display_path().map(|s| s.to_string()),
|
||||
pane.is_dirty,
|
||||
)
|
||||
} else {
|
||||
self.status = "No active file to save".to_string();
|
||||
self.error = Some("Open a file before saving".to_string());
|
||||
return Ok(SaveStatus::Failed);
|
||||
};
|
||||
|
||||
let (content, existing_absolute, existing_display, was_dirty) = pane_snapshot;
|
||||
|
||||
if !was_dirty && path_arg.is_none() && !force {
|
||||
let label =
|
||||
self.buffer_label(existing_display.as_deref(), existing_absolute.as_deref());
|
||||
self.status = format!("No changes to write ({label})");
|
||||
self.error = None;
|
||||
return Ok(SaveStatus::NoChanges);
|
||||
}
|
||||
|
||||
let (request_path, target_absolute, target_display) = if let Some(path_arg) = path_arg {
|
||||
let trimmed = path_arg.trim();
|
||||
if trimmed.is_empty() {
|
||||
self.status = "Save aborted: empty path".to_string();
|
||||
self.error = Some("Provide a path to save this buffer".to_string());
|
||||
return Ok(SaveStatus::Failed);
|
||||
}
|
||||
|
||||
let provided_path = PathBuf::from(trimmed);
|
||||
let absolute = self.absolute_tree_path(&provided_path);
|
||||
let request = if provided_path.is_absolute() {
|
||||
provided_path.to_string_lossy().into_owned()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
};
|
||||
let display = self.display_label_for_absolute(&absolute);
|
||||
(request, absolute, display)
|
||||
} else if let Some(display) = existing_display.clone() {
|
||||
let path = PathBuf::from(&display);
|
||||
let absolute = if path.is_absolute() {
|
||||
path.clone()
|
||||
} else {
|
||||
self.absolute_tree_path(&path)
|
||||
};
|
||||
let display_label = self.display_label_for_absolute(&absolute);
|
||||
(display, absolute, display_label)
|
||||
} else if let Some(absolute) = existing_absolute.clone() {
|
||||
let request = absolute.to_string_lossy().into_owned();
|
||||
let display = self.display_label_for_absolute(&absolute);
|
||||
(request, absolute, display)
|
||||
} else {
|
||||
self.status = "No path associated with buffer".to_string();
|
||||
self.error = Some("Use :w <path> to save this buffer".to_string());
|
||||
return Ok(SaveStatus::Failed);
|
||||
};
|
||||
|
||||
match self.controller.write_file(&request_path, &content).await {
|
||||
Ok(()) => {
|
||||
if let Some(tab) = self.code_workspace.active_tab_mut() {
|
||||
if let Some(pane) = tab.active_pane_mut() {
|
||||
pane.update_paths(
|
||||
Some(target_absolute.clone()),
|
||||
Some(target_display.clone()),
|
||||
);
|
||||
pane.is_dirty = false;
|
||||
pane.is_staged = false;
|
||||
}
|
||||
tab.update_title_from_active();
|
||||
}
|
||||
|
||||
match self.file_tree_mut().refresh() {
|
||||
Ok(()) => {
|
||||
self.file_tree_mut().reveal(&target_absolute);
|
||||
self.ensure_focus_valid();
|
||||
}
|
||||
Err(err) => {
|
||||
self.error = Some(format!(
|
||||
"Saved {} but failed to refresh tree: {}",
|
||||
target_display, err
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
self.status = format!("Wrote {}", target_display);
|
||||
if self.error.is_none() {
|
||||
self.set_system_status(format!("Saved {}", target_display));
|
||||
}
|
||||
Ok(SaveStatus::Saved)
|
||||
}
|
||||
Err(err) => {
|
||||
self.error = Some(format!("Failed to save {}: {}", target_display, err));
|
||||
self.status = format!("Failed to save {}", target_display);
|
||||
Ok(SaveStatus::Failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_active_code_buffer(&mut self, force: bool) -> bool {
|
||||
let snapshot = if let Some(pane) = self.code_workspace.active_pane() {
|
||||
(
|
||||
pane.display_path().map(|s| s.to_string()),
|
||||
pane.absolute_path().map(Path::to_path_buf),
|
||||
pane.is_dirty,
|
||||
)
|
||||
} else {
|
||||
self.status = "No active file to close".to_string();
|
||||
self.error = Some("Open a file before closing it".to_string());
|
||||
return false;
|
||||
};
|
||||
|
||||
let (display_path, absolute_path, is_dirty) = snapshot;
|
||||
|
||||
if is_dirty && !force {
|
||||
let label = self.buffer_label(display_path.as_deref(), absolute_path.as_deref());
|
||||
self.status = format!("Unsaved changes in {label} — use :w to save or :q! to discard");
|
||||
self.error = Some(format!("Unsaved changes detected in {}", label));
|
||||
return false;
|
||||
}
|
||||
|
||||
let label = self.buffer_label(display_path.as_deref(), absolute_path.as_deref());
|
||||
self.close_code_view();
|
||||
self.status = format!("Closed {}", label);
|
||||
self.error = None;
|
||||
self.set_system_status(String::new());
|
||||
true
|
||||
}
|
||||
|
||||
fn split_active_pane(&mut self, axis: SplitAxis) {
|
||||
let Some(snapshot) = self.code_workspace.active_pane().cloned() else {
|
||||
self.status = "No pane to split".to_string();
|
||||
@@ -5038,15 +5209,42 @@ impl ChatApp {
|
||||
// Execute command
|
||||
let cmd_owned = self.command_palette.buffer().trim().to_string();
|
||||
let parts: Vec<&str> = cmd_owned.split_whitespace().collect();
|
||||
let command = parts.first().copied().unwrap_or("");
|
||||
let command_raw = parts.first().copied().unwrap_or("");
|
||||
let args = &parts[1..];
|
||||
|
||||
if !cmd_owned.is_empty() {
|
||||
self.command_palette.remember(&cmd_owned);
|
||||
}
|
||||
|
||||
match command {
|
||||
"q" | "quit" => {
|
||||
let bare_command = command_raw.trim_end_matches('!');
|
||||
let force = bare_command.len() != command_raw.len();
|
||||
|
||||
match bare_command {
|
||||
"" => {}
|
||||
"wq" | "x" => {
|
||||
let path_arg = if args.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(args.join(" "))
|
||||
};
|
||||
let result =
|
||||
self.save_active_code_buffer(path_arg, force).await?;
|
||||
if matches!(result, SaveStatus::Saved | SaveStatus::NoChanges) {
|
||||
self.close_active_code_buffer(force);
|
||||
}
|
||||
}
|
||||
"w" | "write" | "save" => {
|
||||
let path_arg = if args.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(args.join(" "))
|
||||
};
|
||||
let _ = self.save_active_code_buffer(path_arg, force).await?;
|
||||
}
|
||||
"q" => {
|
||||
self.close_active_code_buffer(force);
|
||||
}
|
||||
"quit" => {
|
||||
return Ok(AppState::Quit);
|
||||
}
|
||||
"c" | "clear" => {
|
||||
@@ -5056,45 +5254,64 @@ impl ChatApp {
|
||||
self.clear_new_message_alert();
|
||||
self.status = "Conversation cleared".to_string();
|
||||
}
|
||||
"w" | "write" | "save" => {
|
||||
// Save current conversation with AI-generated description
|
||||
let name = if !args.is_empty() {
|
||||
Some(args.join(" "))
|
||||
"session" => {
|
||||
if let Some(subcommand) = args.first() {
|
||||
match subcommand.to_ascii_lowercase().as_str() {
|
||||
"save" => {
|
||||
let name = if args.len() > 1 {
|
||||
Some(args[1..].join(" "))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let description = if self
|
||||
.controller
|
||||
.config()
|
||||
.storage
|
||||
.generate_descriptions
|
||||
{
|
||||
self.status =
|
||||
"Generating description...".to_string();
|
||||
(self
|
||||
.controller
|
||||
.generate_conversation_description()
|
||||
.await)
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match self
|
||||
.controller
|
||||
.save_active_session(name.clone(), description)
|
||||
.await
|
||||
{
|
||||
Ok(id) => {
|
||||
self.status = if let Some(name) = name {
|
||||
format!("Session saved: {name} ({id})")
|
||||
} else {
|
||||
format!("Session saved with id {id}")
|
||||
};
|
||||
self.error = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.error = Some(format!(
|
||||
"Failed to save session: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
self.error = Some(format!(
|
||||
"Unknown session subcommand: {}",
|
||||
other
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Generate description if enabled in config
|
||||
let description =
|
||||
if self.controller.config().storage.generate_descriptions {
|
||||
self.status = "Generating description...".to_string();
|
||||
(self
|
||||
.controller
|
||||
.generate_conversation_description()
|
||||
.await)
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Save the conversation with description
|
||||
match self
|
||||
.controller
|
||||
.save_active_session(name.clone(), description)
|
||||
.await
|
||||
{
|
||||
Ok(id) => {
|
||||
self.status = if let Some(name) = name {
|
||||
format!("Session saved: {name} ({id})")
|
||||
} else {
|
||||
format!("Session saved with id {id}")
|
||||
};
|
||||
self.error = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.error =
|
||||
Some(format!("Failed to save session: {}", e));
|
||||
}
|
||||
self.status =
|
||||
"Session commands: :session save [name]".to_string();
|
||||
self.error = None;
|
||||
}
|
||||
}
|
||||
"oauth" => {
|
||||
|
||||
@@ -14,7 +14,15 @@ const COMMANDS: &[CommandSpec] = &[
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "q",
|
||||
description: "Alias for quit",
|
||||
description: "Close the active file",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "w",
|
||||
description: "Save the active file",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "write",
|
||||
description: "Alias for w",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "clear",
|
||||
@@ -25,12 +33,16 @@ const COMMANDS: &[CommandSpec] = &[
|
||||
description: "Alias for clear",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "w",
|
||||
description: "Alias for write",
|
||||
keyword: "save",
|
||||
description: "Alias for w",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "save",
|
||||
description: "Alias for write",
|
||||
keyword: "wq",
|
||||
description: "Save and close the active file",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "x",
|
||||
description: "Alias for wq",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "load",
|
||||
@@ -68,6 +80,10 @@ const COMMANDS: &[CommandSpec] = &[
|
||||
keyword: "sessions",
|
||||
description: "List saved sessions",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "session save",
|
||||
description: "Save the current conversation",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "help",
|
||||
description: "Show help documentation",
|
||||
|
||||
@@ -265,6 +265,17 @@ impl CodePane {
|
||||
self.scroll.scroll = 0;
|
||||
}
|
||||
|
||||
pub fn update_paths(&mut self, absolute_path: Option<PathBuf>, display_path: Option<String>) {
|
||||
self.absolute_path = absolute_path;
|
||||
self.display_path = display_path.clone();
|
||||
self.title = self
|
||||
.absolute_path
|
||||
.as_ref()
|
||||
.and_then(|path| path.file_name().map(|s| s.to_string_lossy().into_owned()))
|
||||
.or(display_path)
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.absolute_path = None;
|
||||
self.display_path = None;
|
||||
|
||||
Reference in New Issue
Block a user