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:
2025-10-13 19:42:41 +02:00
parent ae0dd3fc51
commit c81d0f1593
3 changed files with 290 additions and 46 deletions

View File

@@ -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" => {

View File

@@ -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",

View File

@@ -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;