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,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum SaveStatus {
|
||||||
|
Saved,
|
||||||
|
NoChanges,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct ModelSelectorItem {
|
pub(crate) struct ModelSelectorItem {
|
||||||
kind: ModelSelectorItemKind,
|
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) {
|
fn split_active_pane(&mut self, axis: SplitAxis) {
|
||||||
let Some(snapshot) = self.code_workspace.active_pane().cloned() else {
|
let Some(snapshot) = self.code_workspace.active_pane().cloned() else {
|
||||||
self.status = "No pane to split".to_string();
|
self.status = "No pane to split".to_string();
|
||||||
@@ -5038,15 +5209,42 @@ impl ChatApp {
|
|||||||
// Execute command
|
// Execute command
|
||||||
let cmd_owned = self.command_palette.buffer().trim().to_string();
|
let cmd_owned = self.command_palette.buffer().trim().to_string();
|
||||||
let parts: Vec<&str> = cmd_owned.split_whitespace().collect();
|
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..];
|
let args = &parts[1..];
|
||||||
|
|
||||||
if !cmd_owned.is_empty() {
|
if !cmd_owned.is_empty() {
|
||||||
self.command_palette.remember(&cmd_owned);
|
self.command_palette.remember(&cmd_owned);
|
||||||
}
|
}
|
||||||
|
|
||||||
match command {
|
let bare_command = command_raw.trim_end_matches('!');
|
||||||
"q" | "quit" => {
|
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);
|
return Ok(AppState::Quit);
|
||||||
}
|
}
|
||||||
"c" | "clear" => {
|
"c" | "clear" => {
|
||||||
@@ -5056,45 +5254,64 @@ impl ChatApp {
|
|||||||
self.clear_new_message_alert();
|
self.clear_new_message_alert();
|
||||||
self.status = "Conversation cleared".to_string();
|
self.status = "Conversation cleared".to_string();
|
||||||
}
|
}
|
||||||
"w" | "write" | "save" => {
|
"session" => {
|
||||||
// Save current conversation with AI-generated description
|
if let Some(subcommand) = args.first() {
|
||||||
let name = if !args.is_empty() {
|
match subcommand.to_ascii_lowercase().as_str() {
|
||||||
Some(args.join(" "))
|
"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 {
|
} else {
|
||||||
None
|
self.status =
|
||||||
};
|
"Session commands: :session save [name]".to_string();
|
||||||
|
self.error = 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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"oauth" => {
|
"oauth" => {
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ const COMMANDS: &[CommandSpec] = &[
|
|||||||
},
|
},
|
||||||
CommandSpec {
|
CommandSpec {
|
||||||
keyword: "q",
|
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 {
|
CommandSpec {
|
||||||
keyword: "clear",
|
keyword: "clear",
|
||||||
@@ -25,12 +33,16 @@ const COMMANDS: &[CommandSpec] = &[
|
|||||||
description: "Alias for clear",
|
description: "Alias for clear",
|
||||||
},
|
},
|
||||||
CommandSpec {
|
CommandSpec {
|
||||||
keyword: "w",
|
keyword: "save",
|
||||||
description: "Alias for write",
|
description: "Alias for w",
|
||||||
},
|
},
|
||||||
CommandSpec {
|
CommandSpec {
|
||||||
keyword: "save",
|
keyword: "wq",
|
||||||
description: "Alias for write",
|
description: "Save and close the active file",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "x",
|
||||||
|
description: "Alias for wq",
|
||||||
},
|
},
|
||||||
CommandSpec {
|
CommandSpec {
|
||||||
keyword: "load",
|
keyword: "load",
|
||||||
@@ -68,6 +80,10 @@ const COMMANDS: &[CommandSpec] = &[
|
|||||||
keyword: "sessions",
|
keyword: "sessions",
|
||||||
description: "List saved sessions",
|
description: "List saved sessions",
|
||||||
},
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "session save",
|
||||||
|
description: "Save the current conversation",
|
||||||
|
},
|
||||||
CommandSpec {
|
CommandSpec {
|
||||||
keyword: "help",
|
keyword: "help",
|
||||||
description: "Show help documentation",
|
description: "Show help documentation",
|
||||||
|
|||||||
@@ -265,6 +265,17 @@ impl CodePane {
|
|||||||
self.scroll.scroll = 0;
|
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) {
|
pub fn clear(&mut self) {
|
||||||
self.absolute_path = None;
|
self.absolute_path = None;
|
||||||
self.display_path = None;
|
self.display_path = None;
|
||||||
|
|||||||
Reference in New Issue
Block a user