diff --git a/Cargo.lock b/Cargo.lock index 615b8e5..8f14356 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -361,6 +361,7 @@ dependencies = [ "log", "open", "ratatui", + "ratatui-textarea", "serde", "tokio", "tokio-util", @@ -1421,6 +1422,19 @@ dependencies = [ "termwiz", ] +[[package]] +name = "ratatui-textarea" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de236b7cc74b3f7dea227b3fbad97bf459cddf552b6503d888fb9a106eda59ab" +dependencies = [ + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "regex", + "unicode-width", +] + [[package]] name = "ratatui-widgets" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 38013be..0aef7c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] ratatui = { version = "0.30", features = ["serde"] } +ratatui-textarea = { version = "0.8", features = ["search"] } crossterm = "0.29" tokio = { version = "1", features = ["full"] } tokio-util = "0.7" diff --git a/src/action.rs b/src/action.rs index 59affe1..c9a623a 100644 --- a/src/action.rs +++ b/src/action.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + /// All application actions (messages). #[derive(Debug, Clone)] #[allow(dead_code)] @@ -27,7 +29,14 @@ pub enum Action { MoveSelected, DeleteSelected, OpenFile, - OpenEditor, + /// Open `path` in the embedded in-pane editor. + OpenEditor { path: PathBuf }, + /// Close the active editor pane (restores the explorer). + CloseEditor, + /// Save the active editor pane's content to disk. + SaveEditor, + /// Forward a raw key event to the active editor pane. + EditorKeyInput(crossterm::event::KeyEvent), ViewFile, UnpackArchive, Rename, diff --git a/src/app.rs b/src/app.rs index 6693cc6..5f35040 100644 --- a/src/app.rs +++ b/src/app.rs @@ -42,6 +42,7 @@ enum PendingOp { pairs: Vec<(PathBuf, PathBuf)>, }, Delete(Vec), + CloseEditor, } pub struct App { @@ -58,10 +59,8 @@ pub struct App { action_tx: mpsc::UnboundedSender, action_rx: mpsc::UnboundedReceiver, pending_op: Option, - /// Path to open in $EDITOR after the next render cycle (requires TUI suspend). - pub editor_request: Option, /// Path to open in $PAGER after the next render cycle (requires TUI suspend). - pub viewer_request: Option, + viewer_request: Option, } impl App { @@ -87,7 +86,6 @@ impl App { action_tx, action_rx, pending_op: None, - editor_request: None, viewer_request: None, } } @@ -113,15 +111,7 @@ impl App { } } - // Handle editor/viewer requests - requires temporarily releasing the terminal. - if let Some(path) = self.editor_request.take() { - tui::suspend()?; - if let Err(msg) = crate::fs::open::open_in_editor(&path) { - self.dialog = Some(Dialog::error(msg)); - } - tui::resume(&mut terminal)?; - self.dual_pane.active_explorer_mut().refresh(); - } + // Handle viewer requests - requires temporarily releasing the terminal. if let Some(path) = self.viewer_request.take() { tui::suspend()?; if let Err(msg) = crate::fs::open::open_in_viewer(&path) { @@ -256,6 +246,11 @@ impl App { InputMode::Normal => {} } + // If the active pane is an editor, route all remaining keys into it. + if self.dual_pane.active_editor().is_some() { + return Action::EditorKeyInput(key); + } + // Normal mode match (key.modifiers, key.code) { (KeyModifiers::NONE, KeyCode::Up) => Action::MoveUp, @@ -424,7 +419,12 @@ impl App { } Action::ConfirmDialog => { if let Some(op) = self.pending_op.take() { - self.execute_op(op); + match op { + PendingOp::CloseEditor => { + self.dual_pane.close_editor_in_active(); + } + other => self.execute_op(other), + } } self.dialog = None; } @@ -520,7 +520,11 @@ impl App { )); } Action::OpenFile => { - if let Some(entry) = self.dual_pane.active_explorer().current_entry() { + if let Some(entry) = self + .dual_pane + .active_explorer() + .and_then(|e| e.current_entry()) + { if !entry.is_dir { if let Err(msg) = crate::fs::open::open_file(&entry.path) { self.dialog = Some(Dialog::error(msg)); @@ -528,28 +532,64 @@ impl App { } } } - Action::OpenEditor => { - if let Some(entry) = self.dual_pane.active_explorer().current_entry() { - if !entry.is_dir { - self.editor_request = Some(entry.path.clone()); - } + Action::OpenEditor { path } => { + if let Err(msg) = self.dual_pane.open_editor_in_active(path) { + self.dialog = Some(Dialog::error(msg)); + } + } + Action::CloseEditor => { + let modified = self + .dual_pane + .active_editor() + .map(|e| e.modified) + .unwrap_or(false); + if modified { + self.pending_op = Some(PendingOp::CloseEditor); + self.dialog = Some(Dialog::confirm( + "Close editor", + "File has unsaved changes. Discard?", + )); + } else { + self.dual_pane.close_editor_in_active(); + } + } + Action::SaveEditor => { + let result = self + .dual_pane + .active_editor_mut() + .map(|e| e.save()) + .unwrap_or(Ok(())); + if let Err(msg) = result { + self.dialog = Some(Dialog::error(msg)); + } + } + Action::EditorKeyInput(key) => { + if let Some(inner_action) = self.dual_pane.handle_editor_key(key) { + self.dispatch(inner_action); } } Action::ViewFile => { - if let Some(entry) = self.dual_pane.active_explorer().current_entry() { + if let Some(entry) = self + .dual_pane + .active_explorer() + .and_then(|e| e.current_entry()) + { if !entry.is_dir { self.viewer_request = Some(entry.path.clone()); } } } Action::UnpackArchive => { - if let Some(entry) = self.dual_pane.active_explorer().current_entry() { + if let Some(entry) = self + .dual_pane + .active_explorer() + .and_then(|e| e.current_entry()) + { if !entry.is_dir { let dest = self.dual_pane.inactive_dir(); + let cwd = self.dual_pane.active_dir(); match crate::fs::archive::resolve_unpack_command(&entry.path, &dest) { Ok(cmd) => { - let cwd = - self.dual_pane.active_explorer().current_dir.clone(); let tx = self.action_tx.clone(); self.task = Some(TaskState::new(&cmd)); self.input_mode = InputMode::TaskOutput; @@ -563,7 +603,11 @@ impl App { } } Action::Rename => { - if let Some(entry) = self.dual_pane.active_explorer().current_entry() { + if let Some(entry) = self + .dual_pane + .active_explorer() + .and_then(|e| e.current_entry()) + { self.input_mode = InputMode::Rename(entry.name.clone()); } } @@ -578,34 +622,34 @@ impl App { } Action::StartFilter => { self.input_mode = InputMode::Filter(String::new()); - self.dual_pane - .active_explorer_mut() - .handle_action(&Action::StartFilter); + if let Some(e) = self.dual_pane.active_explorer_mut() { + e.handle_action(&Action::StartFilter); + } } Action::FilterInput(ch) => { if let InputMode::Filter(ref mut text) = self.input_mode { text.push(ch); } - self.dual_pane - .active_explorer_mut() - .handle_action(&Action::FilterInput(ch)); + if let Some(e) = self.dual_pane.active_explorer_mut() { + e.handle_action(&Action::FilterInput(ch)); + } } Action::FilterBackspace => { if let InputMode::Filter(ref mut text) = self.input_mode { text.pop(); } - self.dual_pane - .active_explorer_mut() - .handle_action(&Action::FilterBackspace); + if let Some(e) = self.dual_pane.active_explorer_mut() { + e.handle_action(&Action::FilterBackspace); + } } Action::FilterConfirm => { self.input_mode = InputMode::Normal; } Action::FilterCancel => { self.input_mode = InputMode::Normal; - self.dual_pane - .active_explorer_mut() - .handle_action(&Action::FilterCancel); + if let Some(e) = self.dual_pane.active_explorer_mut() { + e.handle_action(&Action::FilterCancel); + } } Action::InputChar(ch) => match self.input_mode { InputMode::Rename(ref mut text) @@ -658,13 +702,11 @@ impl App { self.input_mode = InputMode::Normal; } Action::OperationComplete(msg) => { - self.dual_pane.left.refresh(); - self.dual_pane.right.refresh(); + self.dual_pane.refresh_both(); self.dialog = Some(Dialog::info(msg)); } Action::OperationError(msg) => { - self.dual_pane.left.refresh(); - self.dual_pane.right.refresh(); + self.dual_pane.refresh_both(); self.dialog = Some(Dialog::error(msg)); } Action::OperationProgress { .. } => {} @@ -731,6 +773,12 @@ impl App { Ctrl+R - refresh\n\ Ctrl+Q - quit\n\ \n\ + In editor (Enter on file to open):\n\ + Ctrl+S - save\n\ + Ctrl+Q or Esc - close editor\n\ + Tab - switch to other pane\n\ + Ctrl+I - insert tab character\n\ + \n\ Type any character for command line (cd, shell commands)", )); } @@ -805,12 +853,13 @@ impl App { if let Some(entry) = self.bookmarks.entries.get(cursor) { let path = entry.path.clone(); if path.is_dir() { - let explorer = self.dual_pane.active_explorer_mut(); - explorer.current_dir = path; - explorer.filter_text = None; - explorer.cursor = 0; - explorer.refresh(); - self.bookmark_panel = None; + if let Some(explorer) = self.dual_pane.active_explorer_mut() { + explorer.current_dir = path; + explorer.filter_text = None; + explorer.cursor = 0; + explorer.refresh(); + self.bookmark_panel = None; + } } else { self.dialog = Some(Dialog::error(format!( "Path not found: {}", @@ -822,7 +871,7 @@ impl App { } Action::BookmarkAdd => { // Default name = folder name of active pane. - let dir = self.dual_pane.active_explorer().current_dir.clone(); + let dir = self.dual_pane.active_dir(); let default_name = dir .file_name() .map(|n| n.to_string_lossy().into_owned()) @@ -880,7 +929,7 @@ impl App { .clone(); self.bookmark_panel.as_mut().unwrap().naming = None; if !name.is_empty() { - let path = self.dual_pane.active_explorer().current_dir.clone(); + let path = self.dual_pane.active_dir(); self.bookmarks.add(name, path); // Move cursor to newly added entry. let count = self.bookmarks.entries.len(); @@ -1165,11 +1214,15 @@ impl App { PendingOp::Delete(sources) => { tokio::spawn(ops::delete_entries(sources, tx)); } + // CloseEditor is handled in ConfirmDialog dispatch directly; not routed here. + PendingOp::CloseEditor => {} } } fn get_operation_sources(&self) -> Vec { - let explorer = self.dual_pane.active_explorer(); + let Some(explorer) = self.dual_pane.active_explorer() else { + return vec![]; + }; let mut sources = explorer.selected_paths(); if sources.is_empty() { if let Some(entry) = explorer.current_entry() { @@ -1183,9 +1236,12 @@ impl App { if new_name.is_empty() { return; } - let explorer = self.dual_pane.active_explorer(); - if let Some(entry) = explorer.current_entry() { - let old_path = entry.path.clone(); + let old_path = self + .dual_pane + .active_explorer() + .and_then(|e| e.current_entry()) + .map(|entry| entry.path.clone()); + if let Some(old_path) = old_path { let Some(parent) = old_path.parent() else { return; }; @@ -1193,7 +1249,9 @@ impl App { if let Err(e) = std::fs::rename(&old_path, &new_path) { self.dialog = Some(Dialog::error(format!("Rename failed: {}", e))); } - self.dual_pane.active_explorer_mut().refresh(); + if let Some(e) = self.dual_pane.active_explorer_mut() { + e.refresh(); + } } } @@ -1201,26 +1259,30 @@ impl App { if name.is_empty() { return; } - let dir = self.dual_pane.active_explorer().current_dir.clone(); + let dir = self.dual_pane.active_dir(); let new_dir = dir.join(name); if let Err(e) = std::fs::create_dir(&new_dir) { self.dialog = Some(Dialog::error(format!("Mkdir failed: {}", e))); } - self.dual_pane.active_explorer_mut().refresh(); + if let Some(e) = self.dual_pane.active_explorer_mut() { + e.refresh(); + } } fn do_create_file(&mut self, name: &str) { if name.is_empty() { return; } - let dir = self.dual_pane.active_explorer().current_dir.clone(); + let dir = self.dual_pane.active_dir(); let new_file = dir.join(name); if new_file.exists() { self.dialog = Some(Dialog::error(format!("Already exists: {}", name))); } else if let Err(e) = std::fs::File::create(&new_file) { self.dialog = Some(Dialog::error(format!("Create file failed: {}", e))); } - self.dual_pane.active_explorer_mut().refresh(); + if let Some(e) = self.dual_pane.active_explorer_mut() { + e.refresh(); + } } fn do_command(&mut self, cmd: &str) { @@ -1252,16 +1314,17 @@ impl App { let target = if PathBuf::from(&path_str).is_absolute() { PathBuf::from(&path_str) } else { - self.dual_pane.active_explorer().current_dir.join(&path_str) + self.dual_pane.active_dir().join(&path_str) }; match clean_canonicalize(&target) { Ok(resolved) if resolved.is_dir() => { - let explorer = self.dual_pane.active_explorer_mut(); - explorer.current_dir = resolved; - explorer.filter_text = None; - explorer.cursor = 0; - explorer.refresh(); + if let Some(explorer) = self.dual_pane.active_explorer_mut() { + explorer.current_dir = resolved; + explorer.filter_text = None; + explorer.cursor = 0; + explorer.refresh(); + } } Ok(_) => { self.dialog = @@ -1282,7 +1345,7 @@ impl App { return; } - let cwd = self.dual_pane.active_explorer().current_dir.clone(); + let cwd = self.dual_pane.active_dir(); self.task = Some(TaskState::new(cmd)); self.input_mode = InputMode::TaskOutput; let tx = self.action_tx.clone(); @@ -1291,8 +1354,8 @@ impl App { fn save_session(&self) { let config = SessionConfig { - left_dir: self.dual_pane.left.current_dir.clone(), - right_dir: self.dual_pane.right.current_dir.clone(), + left_dir: self.dual_pane.left_dir(), + right_dir: self.dual_pane.right_dir(), active_pane: self.dual_pane.active, theme_name: self.theme_name.clone(), }; diff --git a/src/components/dual_pane.rs b/src/components/dual_pane.rs index b11fb09..bd999a7 100644 --- a/src/components/dual_pane.rs +++ b/src/components/dual_pane.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use crossterm::event::KeyEvent; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; @@ -7,46 +8,143 @@ use crate::action::Action; use crate::config::PaneSide; use crate::theme::Theme; +use super::editor_pane::EditorPane; use super::explorer::Explorer; +/// Contents of a single pane slot - either a file explorer or an embedded editor. +pub enum PaneContent { + Explorer(Explorer), + Editor(Box), +} + +impl PaneContent { + fn refresh(&mut self) { + if let PaneContent::Explorer(e) = self { + e.refresh(); + } + } + + /// Return the "current directory" for this pane regardless of its type. + pub fn current_dir(&self) -> PathBuf { + match self { + PaneContent::Explorer(e) => e.current_dir.clone(), + PaneContent::Editor(e) => e.origin_dir.clone(), + } + } +} + pub struct DualPane { - pub left: Explorer, - pub right: Explorer, + pub left: PaneContent, + pub right: PaneContent, pub active: PaneSide, } impl DualPane { pub fn new(left_dir: PathBuf, right_dir: PathBuf, active: PaneSide) -> Self { Self { - left: Explorer::new(left_dir), - right: Explorer::new(right_dir), + left: PaneContent::Explorer(Explorer::new(left_dir)), + right: PaneContent::Explorer(Explorer::new(right_dir)), active, } } - pub fn active_explorer(&self) -> &Explorer { + fn active_content(&self) -> &PaneContent { match self.active { PaneSide::Left => &self.left, PaneSide::Right => &self.right, } } - pub fn active_explorer_mut(&mut self) -> &mut Explorer { + fn active_content_mut(&mut self) -> &mut PaneContent { match self.active { PaneSide::Left => &mut self.left, PaneSide::Right => &mut self.right, } } - pub fn inactive_explorer(&self) -> &Explorer { + fn inactive_content(&self) -> &PaneContent { match self.active { PaneSide::Left => &self.right, PaneSide::Right => &self.left, } } + pub fn active_explorer(&self) -> Option<&Explorer> { + match self.active_content() { + PaneContent::Explorer(e) => Some(e), + PaneContent::Editor(_) => None, + } + } + + pub fn active_explorer_mut(&mut self) -> Option<&mut Explorer> { + match self.active_content_mut() { + PaneContent::Explorer(e) => Some(e), + PaneContent::Editor(_) => None, + } + } + + pub fn active_editor(&self) -> Option<&EditorPane> { + match self.active_content() { + PaneContent::Editor(e) => Some(e), + PaneContent::Explorer(_) => None, + } + } + + pub fn active_editor_mut(&mut self) -> Option<&mut EditorPane> { + match self.active_content_mut() { + PaneContent::Editor(e) => Some(e), + PaneContent::Explorer(_) => None, + } + } + + pub fn active_dir(&self) -> PathBuf { + self.active_content().current_dir() + } + pub fn inactive_dir(&self) -> PathBuf { - self.inactive_explorer().current_dir.clone() + self.inactive_content().current_dir() + } + + pub fn left_dir(&self) -> PathBuf { + self.left.current_dir() + } + + pub fn right_dir(&self) -> PathBuf { + self.right.current_dir() + } + + pub fn refresh_both(&mut self) { + self.left.refresh(); + self.right.refresh(); + } + + pub fn open_editor_in_active(&mut self, path: PathBuf) -> Result<(), String> { + let origin_dir = self.active_dir(); + let editor = EditorPane::open(path, origin_dir, false)?; + match self.active { + PaneSide::Left => self.left = PaneContent::Editor(Box::new(editor)), + PaneSide::Right => self.right = PaneContent::Editor(Box::new(editor)), + } + Ok(()) + } + + pub fn close_editor_in_active(&mut self) { + let origin_dir = match self.active_content() { + PaneContent::Editor(e) => e.origin_dir.clone(), + PaneContent::Explorer(_) => return, + }; + match self.active { + PaneSide::Left => self.left = PaneContent::Explorer(Explorer::new(origin_dir)), + PaneSide::Right => self.right = PaneContent::Explorer(Explorer::new(origin_dir)), + } + } + + pub fn handle_editor_key(&mut self, key: KeyEvent) -> Option { + if let Some(editor) = self.active_editor_mut() { + editor.handle_key(key) + } else { + None + } } pub fn handle_action(&mut self, action: &Action) -> Option { @@ -67,11 +165,16 @@ impl DualPane { None } Action::Refresh => { - self.left.refresh(); - self.right.refresh(); + self.refresh_both(); None } - _ => self.active_explorer_mut().handle_action(action), + _ => { + if let PaneContent::Explorer(explorer) = self.active_content_mut() { + explorer.handle_action(action) + } else { + None + } + } } } @@ -81,9 +184,34 @@ impl DualPane { .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(area); - self.left - .draw(frame, chunks[0], self.active == PaneSide::Left, theme); - self.right - .draw(frame, chunks[1], self.active == PaneSide::Right, theme); + let left_active = self.active == PaneSide::Left; + let right_active = self.active == PaneSide::Right; + + match &mut self.left { + PaneContent::Explorer(e) => e.draw(frame, chunks[0], left_active, theme), + PaneContent::Editor(e) => e.draw(frame, chunks[0], left_active, theme), + } + match &mut self.right { + PaneContent::Explorer(e) => e.draw(frame, chunks[1], right_active, theme), + PaneContent::Editor(e) => e.draw(frame, chunks[1], right_active, theme), + } + } +} + +impl PaneContent { + #[allow(dead_code)] + pub fn as_explorer(&self) -> Option<&Explorer> { + match self { + PaneContent::Explorer(e) => Some(e), + PaneContent::Editor(_) => None, + } + } + + #[allow(dead_code)] + pub fn as_explorer_mut(&mut self) -> Option<&mut Explorer> { + match self { + PaneContent::Explorer(e) => Some(e), + PaneContent::Editor(_) => None, + } } } diff --git a/src/components/editor_pane.rs b/src/components/editor_pane.rs new file mode 100644 index 0000000..49d974c --- /dev/null +++ b/src/components/editor_pane.rs @@ -0,0 +1,165 @@ +use std::fs; +use std::path::PathBuf; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders}; +use ratatui_textarea::TextArea; + +use crate::action::Action; +use crate::theme::Theme; + +pub struct EditorPane { + pub path: PathBuf, + pub textarea: TextArea<'static>, + pub read_only: bool, + pub modified: bool, + /// Directory from which the editor was opened - restored when editor is closed. + pub origin_dir: PathBuf, +} + +impl EditorPane { + pub fn open(path: PathBuf, origin_dir: PathBuf, read_only: bool) -> Result { + let content = fs::read_to_string(&path) + .map_err(|e| format!("Cannot open '{}': {}", path.display(), e))?; + + // Split into lines preserving empty trailing lines + let lines: Vec = if content.is_empty() { + vec![String::new()] + } else { + let mut ls: Vec = content.lines().map(str::to_string).collect(); + // If file ends with newline, add an empty line so cursor can sit there + if content.ends_with('\n') { + ls.push(String::new()); + } + ls + }; + + let mut textarea: TextArea<'static> = TextArea::new(lines); + + // Dim line numbers + textarea.set_line_number_style(Style::default().add_modifier(Modifier::DIM)); + // No special highlighting on the cursor line itself + textarea.set_cursor_line_style(Style::default()); + // Tab width = 4 + textarea.set_tab_length(4); + + Ok(Self { + path, + textarea, + read_only, + modified: false, + origin_dir, + }) + } + + /// Write the textarea contents back to the file. + pub fn save(&mut self) -> Result<(), String> { + if self.read_only { + return Err("File is read-only".to_string()); + } + let lines = self.textarea.lines(); + // Join lines with newline; strip the synthetic trailing empty line if present + let content = if lines.last().map(|l| l.is_empty()).unwrap_or(false) && lines.len() > 1 { + let joined = lines[..lines.len() - 1].join("\n"); + format!("{}\n", joined) + } else { + lines.join("\n") + }; + fs::write(&self.path, content) + .map_err(|e| format!("Cannot save '{}': {}", self.path.display(), e))?; + self.modified = false; + Ok(()) + } + + /// Process a key event from the app event loop. + /// Returns `Some(Action)` for app-level actions; `None` if handled internally. + pub fn handle_key(&mut self, key: KeyEvent) -> Option { + match (key.modifiers, key.code) { + // Ctrl+S - save + (KeyModifiers::CONTROL, KeyCode::Char('s')) => { + return Some(Action::SaveEditor); + } + // Ctrl+Q or Esc - close editor + (KeyModifiers::CONTROL, KeyCode::Char('q')) | (_, KeyCode::Esc) => { + return Some(Action::CloseEditor); + } + // Tab - switch to other pane (Ctrl+I inserts a literal tab) + (KeyModifiers::NONE, KeyCode::Tab) => { + return Some(Action::SwitchPane); + } + _ => { + if self.read_only { + // Read-only: allow navigation keys via textarea (scrolling) + self.textarea.input(key); + } else { + let changed = self.textarea.input(key); + if changed { + self.modified = true; + } + } + } + } + None + } + + /// Returns the current `(row, col)` cursor position (0-indexed). + pub fn cursor(&self) -> (usize, usize) { + self.textarea.cursor() + } + + pub fn draw(&mut self, frame: &mut Frame, area: Rect, is_active: bool, theme: &Theme) { + let border_style = if is_active { + Style::default().fg(theme.border_focused) + } else { + Style::default().fg(theme.border_unfocused) + }; + + // Build title: filename [+] [RO] line:col + let filename = self + .path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| self.path.to_string_lossy().into_owned()); + + let mut label = filename; + if self.modified { + label.push_str(" [+]"); + } + if self.read_only { + label.push_str(" [RO]"); + } + + let (row, col) = self.textarea.cursor(); + let pos = format!(" {}:{} ", row + 1, col + 1); + + let title = Line::from(vec![ + Span::raw(" "), + Span::styled(label, border_style), + Span::styled(pos, Style::default().fg(theme.border_unfocused)), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(title); + + // Render the border block first, then put the textarea inside it + let inner = block.inner(area); + frame.render_widget(block, area); + + // Toggle cursor visibility based on focus + if is_active { + self.textarea + .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); + } else { + // Hide cursor in inactive pane + self.textarea.set_cursor_style(Style::default()); + } + + frame.render_widget(&self.textarea, inner); + } +} diff --git a/src/components/explorer.rs b/src/components/explorer.rs index 2c92e0d..834919d 100644 --- a/src/components/explorer.rs +++ b/src/components/explorer.rs @@ -161,7 +161,7 @@ impl Explorer { } else if crate::util::is_archive(&entry.name) { return Some(Action::UnpackArchive); } else { - return Some(Action::OpenEditor); + return Some(Action::OpenEditor { path: entry.path.clone() }); } } None diff --git a/src/components/mod.rs b/src/components/mod.rs index c90c8cd..b8d0e4e 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -2,6 +2,7 @@ pub mod bookmark_panel; pub mod command_bar; pub mod dialog; pub mod dual_pane; +pub mod editor_pane; pub mod explorer; pub mod status_bar; pub mod task_window; diff --git a/src/components/status_bar.rs b/src/components/status_bar.rs index ea610b6..c71c354 100644 --- a/src/components/status_bar.rs +++ b/src/components/status_bar.rs @@ -17,7 +17,24 @@ pub fn draw_status_bar( input_mode: &InputMode, theme: &Theme, ) { - let explorer = dual_pane.active_explorer(); + // When the active pane is an editor, show the file path and cursor position. + if let Some(editor) = dual_pane.active_editor() { + let path = editor.path.to_string_lossy().to_string(); + let (row, col) = editor.cursor(); + let info = format!("{} Ln {}, Col {}", path, row + 1, col + 1); + let paragraph = Paragraph::new(Line::from(vec![ + Span::styled(info, Style::default().fg(theme.path_fg)), + ])) + .style(Style::default().bg(theme.status_bg).fg(theme.status_fg)); + frame.render_widget(paragraph, area); + return; + } + + // Explorer mode + let explorer = match dual_pane.active_explorer() { + Some(e) => e, + None => return, + }; let path = explorer.current_dir.to_string_lossy().to_string(); let mut spans = vec![ diff --git a/src/fs/open.rs b/src/fs/open.rs index a4aca05..5cc436f 100644 --- a/src/fs/open.rs +++ b/src/fs/open.rs @@ -6,6 +6,7 @@ pub fn open_file(path: &Path) -> Result<(), String> { /// Resolve the editor binary to use, following the chain: /// $EDITOR -> $VISUAL -> nano -> vi (Unix) / notepad (Windows) +#[allow(dead_code)] pub fn resolve_editor() -> String { if let Ok(e) = std::env::var("EDITOR") { if !e.trim().is_empty() { @@ -48,6 +49,7 @@ pub fn resolve_pager() -> String { } /// Open `path` in the user's editor (blocking - caller must suspend TUI first). +#[allow(dead_code)] pub fn open_in_editor(path: &Path) -> Result<(), String> { let editor = resolve_editor(); std::process::Command::new(&editor) diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs index e53abf2..855fc0e 100644 --- a/tests/unit_tests.rs +++ b/tests/unit_tests.rs @@ -805,7 +805,7 @@ fn explorer_enter_on_file_returns_open_editor() { explorer.handle_action(&cpt::action::Action::MoveDown); // onto the file let result = explorer.handle_action(&cpt::action::Action::EnterDir); - assert!(matches!(result, Some(cpt::action::Action::OpenEditor))); + assert!(matches!(result, Some(cpt::action::Action::OpenEditor { .. }))); fs::remove_dir_all(&dir).ok(); } @@ -1008,12 +1008,12 @@ fn dual_pane_refresh_both() { right.clone(), cpt::config::PaneSide::Left, ); - assert_eq!(dual.left.filtered.len(), 1); + assert_eq!(dual.left.as_explorer().unwrap().filtered.len(), 1); // Add a file, then refresh fs::write(left.join("b.txt"), "").unwrap(); dual.handle_action(&cpt::action::Action::Refresh); - assert_eq!(dual.left.filtered.len(), 2); + assert_eq!(dual.left.as_explorer().unwrap().filtered.len(), 2); fs::remove_dir_all(&left).ok(); fs::remove_dir_all(&right).ok(); @@ -1036,14 +1036,14 @@ fn dual_pane_forwards_to_active() { // MoveDown on left pane dual.handle_action(&cpt::action::Action::MoveDown); - assert_eq!(dual.left.cursor, 1); - assert_eq!(dual.right.cursor, 0); // Unchanged + assert_eq!(dual.left.as_explorer().unwrap().cursor, 1); + assert_eq!(dual.right.as_explorer().unwrap().cursor, 0); // Unchanged // Switch and move on right pane dual.handle_action(&cpt::action::Action::SwitchPane); dual.handle_action(&cpt::action::Action::MoveDown); - assert_eq!(dual.right.cursor, 1); - assert_eq!(dual.left.cursor, 1); // Still unchanged + assert_eq!(dual.right.as_explorer().unwrap().cursor, 1); + assert_eq!(dual.left.as_explorer().unwrap().cursor, 1); // Still unchanged fs::remove_dir_all(&left).ok(); fs::remove_dir_all(&right).ok(); @@ -1377,14 +1377,14 @@ fn dual_pane_actions_only_affect_active() { // Select all on left dual.handle_action(&cpt::action::Action::SelectAll); - assert_eq!(dual.left.selected.len(), 1); - assert_eq!(dual.right.selected.len(), 0); + assert_eq!(dual.left.as_explorer().unwrap().selected.len(), 1); + assert_eq!(dual.right.as_explorer().unwrap().selected.len(), 0); // Switch and select all on right dual.handle_action(&cpt::action::Action::SwitchPane); dual.handle_action(&cpt::action::Action::SelectAll); - assert_eq!(dual.left.selected.len(), 1); // unchanged - assert_eq!(dual.right.selected.len(), 1); + assert_eq!(dual.left.as_explorer().unwrap().selected.len(), 1); // unchanged + assert_eq!(dual.right.as_explorer().unwrap().selected.len(), 1); fs::remove_dir_all(&left).ok(); fs::remove_dir_all(&right).ok();