diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 655fe1f..ed519a2 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -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, + force: bool, + ) -> Result { + 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 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" => { diff --git a/crates/owlen-tui/src/commands/mod.rs b/crates/owlen-tui/src/commands/mod.rs index b852d99..a29444e 100644 --- a/crates/owlen-tui/src/commands/mod.rs +++ b/crates/owlen-tui/src/commands/mod.rs @@ -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", diff --git a/crates/owlen-tui/src/state/workspace.rs b/crates/owlen-tui/src/state/workspace.rs index 8159afd..d2eed3a 100644 --- a/crates/owlen-tui/src/state/workspace.rs +++ b/crates/owlen-tui/src/state/workspace.rs @@ -265,6 +265,17 @@ impl CodePane { self.scroll.scroll = 0; } + pub fn update_paths(&mut self, absolute_path: Option, display_path: Option) { + 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;