From 59a4672acd3acf443b550808688f702e48bc282b Mon Sep 17 00:00:00 2001 From: vasilito Date: Fri, 19 Jun 2026 06:49:15 +0300 Subject: [PATCH] tlc: Ctrl-X chord system, Compare Dirs, SymlinkRelative/Edit, format paragraph, viewer tail-f - Ctrl-X prefix chord: dispatch_ctrl_x_followup routes 9 follow-up keys (d/j/c/o/l/s/v/a/!) to their respective commands - Compare Dirs (C-x d): size-only mode matching MC behavior, marks files that differ between left and right panels - SymlinkRelative (C-x s): creates symlinks with relative target paths via relpath_from() helper - SymlinkEdit (C-x v): reads existing symlink target, opens edit dialog with for_editing() constructor, removes old link before recreating - ScreenList/EditHistory/FilteredView dialogs wired through dispatcher, handle_dialog_key, apply_finished_dialog, and render - Editor format paragraph (Alt-P): wrap_paragraph + paragraph_range + reformat_paragraph_at in format.rs, 29 unit tests - Viewer growing buffer: toggle_growing/check_growing for tail -f mode, detects file growth and appends new content 946 tests pass (default), 964 (all features), 0 failures --- local/recipes/tui/tlc/source/src/app.rs | 16 + .../tui/tlc/source/src/editor/format.rs | 296 ++++++++++++++++ .../recipes/tui/tlc/source/src/editor/mod.rs | 111 ++++++ .../tui/tlc/source/src/filemanager/link.rs | 127 +++++-- .../tui/tlc/source/src/filemanager/mod.rs | 316 +++++++++++++++++- .../recipes/tui/tlc/source/src/viewer/mod.rs | 191 ++++++++++- 6 files changed, 1023 insertions(+), 34 deletions(-) diff --git a/local/recipes/tui/tlc/source/src/app.rs b/local/recipes/tui/tlc/source/src/app.rs index df65a82612..c0cfc166c9 100644 --- a/local/recipes/tui/tlc/source/src/app.rs +++ b/local/recipes/tui/tlc/source/src/app.rs @@ -181,6 +181,22 @@ impl Application { continue; } + // Ctrl-X prefix: set flag, wait for next key. + if key == Key::ctrl('x') { + fm.pending_ctrl_x = true; + fm.status.set_message("Ctrl-X".to_string()); + render(&mut tui, &mut fm)?; + continue; + } + // If pending Ctrl-X, dispatch follow-up. + if fm.pending_ctrl_x { + if let Err(e) = fm.dispatch_ctrl_x_followup(key) { + fm.status.set_message(format!("Ctrl-X: {e}")); + } + render(&mut tui, &mut fm)?; + continue; + } + // Auto-activate the command line when the user types a // printable character (MC behavior: just start typing). if !fm.cmdline.is_active() { diff --git a/local/recipes/tui/tlc/source/src/editor/format.rs b/local/recipes/tui/tlc/source/src/editor/format.rs index 0c248de745..4c53b3aaa1 100644 --- a/local/recipes/tui/tlc/source/src/editor/format.rs +++ b/local/recipes/tui/tlc/source/src/editor/format.rs @@ -110,6 +110,163 @@ impl FileFormat { /// The UTF-8 byte-order mark: `EF BB BF`. const BOM_UTF8: &[u8] = &[0xEF, 0xBB, 0xBF]; +/// Default right-margin column used by [`wrap_paragraph`] when the +/// caller does not specify a width. Matches Midnight Commander's +/// default `editor_word_wrap_line_length` of 72. +pub const DEFAULT_WRAP_WIDTH: usize = 72; + +/// Hard upper bound for the wrap width. Callers asking for a margin +/// beyond this value are silently clamped, so a misconfigured skin +/// can never produce absurdly long wrapped lines. +pub const MAX_WRAP_WIDTH: usize = 1024; + +/// Re-wrap `text` to fit within `width` columns. +/// +/// Words are preserved whole — only the whitespace between them +/// changes. A single word longer than `width` is emitted on its own +/// line; we never split a word with a hyphen. Lines are joined +/// with `'\n'`. The result has no trailing newline. +#[must_use] +pub fn wrap_paragraph(text: &str, width: usize) -> String { + let width = width.clamp(1, MAX_WRAP_WIDTH); + let mut out = String::with_capacity(text.len()); + let mut line_len: usize = 0; + let mut at_line_start = true; + for word in text.split_whitespace() { + let wlen = word.chars().count(); + if at_line_start { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(word); + line_len = wlen; + at_line_start = false; + } else if line_len + 1 + wlen <= width { + out.push(' '); + out.push_str(word); + line_len += 1 + wlen; + } else { + out.push('\n'); + out.push_str(word); + line_len = wlen; + } + } + out +} + +/// Find the byte offsets `[start, end)` of the paragraph that +/// contains the byte offset `cursor` inside `text`. +/// +/// A paragraph is a contiguous run of non-blank lines bounded by +/// blank lines or the start/end of the file. Blank lines contain +/// only whitespace (no other characters). The returned range is +/// half-open, always lies within `text`, and does NOT include the +/// trailing newline of the paragraph's last line — the caller can +/// re-attach the separator by reading the next `'\n'` after `end`. +/// +/// If the cursor is on a blank line, the returned range is empty +/// (`start == end == cursor`) — there is nothing to format. If +/// `cursor` is past the end of the text, the search clamps to the +/// end (so the function never panics on out-of-range cursors). +#[must_use] +pub fn paragraph_range(text: &str, cursor: usize) -> (usize, usize) { + let cursor = cursor.min(text.len()); + let mut line_starts: Vec = vec![0usize]; + let bytes = text.as_bytes(); + let last_newline = if text.ends_with('\n') && !bytes.is_empty() { + bytes.len() - 1 + } else { + usize::MAX + }; + for (i, b) in bytes.iter().enumerate() { + if *b == b'\n' && i != last_newline { + line_starts.push(i + 1); + } + } + if !text.ends_with('\n') { + line_starts.push(text.len()); + } + let ends_with_nl = text.ends_with('\n'); + let cursor_at_text_end = cursor == text.len(); + let line_idx = if !ends_with_nl && cursor_at_text_end { + line_starts.len().saturating_sub(2) + } else { + match line_starts.binary_search(&cursor) { + Ok(i) => i, + Err(i) => i.saturating_sub(1), + } + }; + let is_blank = |line_start: usize| -> bool { + if line_start > text.len() { + return false; + } + if line_start == text.len() { + return true; + } + let line_end = text[line_start..] + .find('\n') + .map(|n| line_start + n) + .unwrap_or(text.len()); + text[line_start..line_end] + .chars() + .all(|c| c.is_whitespace()) + }; + if is_blank(line_starts[line_idx]) { + return (cursor, cursor); + } + let mut para_start_line = line_idx; + while para_start_line > 0 && !is_blank(line_starts[para_start_line - 1]) { + para_start_line -= 1; + } + let mut para_end_line = line_idx; + while para_end_line + 1 < line_starts.len() && !is_blank(line_starts[para_end_line + 1]) { + para_end_line += 1; + } + let start = line_starts[para_start_line]; + let last_line_start = line_starts[para_end_line]; + let end = text[last_line_start..] + .find('\n') + .map(|n| last_line_start + n) + .unwrap_or(text.len()); + (start, end) +} + +/// Reformat the paragraph that contains the byte offset `cursor` +/// in `text`. Returns the new full text with that one paragraph +/// replaced by the wrapped version. +/// +/// The paragraph is the contiguous block of non-blank lines that +/// contains `cursor`. Blank lines are preserved. Lines are joined +/// with single spaces; the result is re-wrapped to `width` columns. +/// The original inter-paragraph separator (the newline that +/// terminates the paragraph) is preserved verbatim. If the cursor +/// is on a blank line, `text` is returned unchanged. +/// +/// This is a pure string function — it does not touch the +/// [`Buffer`] or its undo stack. The caller is responsible for +/// recording the pre-edit state if undo is desired. +#[must_use] +pub fn reformat_paragraph_at(text: &str, cursor: usize, width: usize) -> String { + let width = width.clamp(1, MAX_WRAP_WIDTH); + let (para_start, para_end) = paragraph_range(text, cursor); + if para_start == para_end { + return text.to_string(); + } + let paragraph = &text[para_start..para_end]; + let para_with_newline_end = text[para_end..] + .find('\n') + .map(|n| para_end + n + 1) + .unwrap_or(text.len()); + let trailing = &text[para_end..para_with_newline_end]; + let wrapped = wrap_paragraph(paragraph, width); + let mut out = String::with_capacity(text.len()); + out.push_str(&text[..para_start]); + out.push_str(&wrapped); + out.push_str(trailing); + out.push_str(&text[para_with_newline_end..]); + out +} + /// Compute the leading whitespace of a line — the maximal run of /// `' '` and `'\t'` characters at the start of `line`. /// @@ -325,4 +482,143 @@ mod tests { assert_eq!(inserted, 1 + 8); assert_eq!(b.as_string(), " if x {\n "); } + + // --- wrap_paragraph tests --- + + #[test] + fn wrap_paragraph_simple_short_text() { + let out = wrap_paragraph("hello world", 72); + assert_eq!(out, "hello world"); + } + + #[test] + fn wrap_paragraph_collapses_whitespace() { + let out = wrap_paragraph("hello world", 72); + assert_eq!(out, "hello world"); + } + + #[test] + fn wrap_paragraph_wraps_long_line() { + let out = wrap_paragraph("the quick brown fox jumps over", 10); + assert_eq!(out, "the quick\nbrown fox\njumps over"); + } + + #[test] + fn wrap_paragraph_long_word_goes_on_own_line() { + let out = wrap_paragraph("short supercalifragilisticexpialidocious end", 10); + assert_eq!( + out, + "short\nsupercalifragilisticexpialidocious\nend" + ); + } + + #[test] + fn wrap_paragraph_empty_input() { + assert_eq!(wrap_paragraph("", 72), ""); + assert_eq!(wrap_paragraph(" \t ", 72), ""); + } + + #[test] + fn wrap_paragraph_clamps_width_to_min_one() { + let out = wrap_paragraph("a b c", 0); + assert_eq!(out, "a\nb\nc"); + } + + #[test] + fn wrap_paragraph_clamps_width_to_max() { + let text = "a b c d e f g h i j k l m n o p q r s t u v w x y z"; + let out = wrap_paragraph(text, 9999); + assert_eq!(out, text); + } + + // --- paragraph_range tests --- + + #[test] + fn paragraph_range_first_paragraph() { + let text = "aaa\nbbb\n\nccc"; + let (start, end) = paragraph_range(text, 0); + assert_eq!(&text[start..end], "aaa\nbbb"); + } + + #[test] + fn paragraph_range_second_paragraph() { + let text = "aaa\nbbb\n\nccc\nddd"; + let (start, end) = paragraph_range(text, 9); + assert_eq!(&text[start..end], "ccc\nddd"); + } + + #[test] + fn paragraph_range_on_blank_line_is_empty() { + let text = "aaa\n\nbbb"; + let (start, end) = paragraph_range(text, 4); + assert_eq!(start, end); + assert_eq!(start, 4); + } + + #[test] + fn paragraph_range_cursor_clamps_past_end() { + let text = "aaa"; + let (start, end) = paragraph_range(text, 100); + assert_eq!(&text[start..end], "aaa"); + } + + #[test] + fn paragraph_range_preserves_leading_whitespace() { + let text = " indented line\n more indent\n\nafter"; + let cursor = text.find("more").expect("present"); + let (start, end) = paragraph_range(text, cursor); + assert_eq!(&text[start..end], " indented line\n more indent"); + } + + // --- reformat_paragraph_at tests --- + + #[test] + fn reformat_paragraph_wraps_three_short_lines() { + let text = "the quick brown fox\njumps over the lazy\ndog today\n"; + let out = reformat_paragraph_at(text, 0, 30); + assert_eq!(out, "the quick brown fox jumps over\nthe lazy dog today\n"); + } + + #[test] + fn reformat_paragraph_already_formatted_is_idempotent() { + let text = "the quick brown fox\njumps over the lazy\ndog today\n"; + let expected = "the quick brown fox\njumps over the lazy\ndog today\n"; + let out = reformat_paragraph_at(text, 0, 20); + assert_eq!(out, expected); + let out2 = reformat_paragraph_at(&out, 0, 20); + assert_eq!(out2, expected); + } + + #[test] + fn reformat_paragraph_collapses_excess_whitespace() { + let text = "the quick\tbrown\n fox jumps\n"; + let out = reformat_paragraph_at(text, 0, 72); + assert_eq!(out, "the quick brown fox jumps\n"); + } + + #[test] + fn reformat_paragraph_preserves_blank_line_separator() { + let text = "short\n\nnext paragraph here\n"; + let out = reformat_paragraph_at(text, 0, 72); + assert_eq!(out, "short\n\nnext paragraph here\n"); + } + + #[test] + fn reformat_paragraph_on_blank_line_is_noop() { + let text = "aaa\n\nbbb\n"; + let out = reformat_paragraph_at(text, 4, 72); + assert_eq!(out, text); + } + + #[test] + fn reformat_paragraph_handles_missing_trailing_newline() { + let text = "aaa bbb\nccc"; + let out = reformat_paragraph_at(text, 0, 72); + assert_eq!(out, "aaa bbb ccc"); + } + + #[test] + fn reformat_paragraph_uses_default_width_constant() { + assert_eq!(DEFAULT_WRAP_WIDTH, 72); + } } diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index 4acc6ed860..cc0d92c5b5 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -396,6 +396,42 @@ impl Editor { } } + /// Alt-P — reformat the current paragraph. + /// + /// Walks the contiguous block of non-blank lines that contains + /// the cursor, joins them into one wrapped block at + /// [`format::DEFAULT_WRAP_WIDTH`], and replaces the original + /// lines. Blank lines around the paragraph are preserved. + /// + /// The edit is undoable: `begin_undo_group` records a snapshot + /// before the change, so a single `Ctrl-Z` restores the + /// pre-format text. On a no-op (cursor on a blank line) the + /// method leaves the buffer untouched and records no undo + /// state. + pub fn format_paragraph(&mut self) { + let text = self.buffer.as_string(); + let cursor = self.cursor.position(); + let new_text = format::reformat_paragraph_at(&text, cursor, format::DEFAULT_WRAP_WIDTH); + if new_text == text { + return; + } + let (old_para_start, old_para_end) = format::paragraph_range(&text, cursor); + let (new_para_start, new_para_end_excl_nl) = + format::paragraph_range(&new_text, old_para_start.min(new_text.len())); + self.buffer.begin_undo_group(); + self.buffer.set_cursor(old_para_start); + for _ in old_para_start..old_para_end { + self.buffer.delete_forward(); + } + self.buffer.insert_str(&new_text[new_para_start..new_para_end_excl_nl]); + let new_cursor = new_para_start; + self.buffer.set_cursor(new_cursor); + self.cursor.set_position(new_cursor, &self.buffer); + self.buffer.end_undo_group(); + self.modified = true; + self.message = Some("Formatted paragraph".to_string()); + } + /// Handle a key event. Returns the [`EditorResult`] for the /// application loop. Dispatches to Normal/Insert/Prompt /// handlers based on the current [`Mode`]. @@ -484,6 +520,10 @@ impl Editor { self.match_bracket(); return Some(EditorResult::Running); } + 0x70 => { + self.format_paragraph(); + return Some(EditorResult::Running); + } _ => {} } } @@ -2491,5 +2531,76 @@ mod tests { assert_eq!(n_cell.bg, marked_pair.bg, "selected 'n' cell bg"); let _ = fs::remove_dir_all(&dir); } + + // ----------------------------------------------------------------- + // Format-paragraph (Alt-P) tests + // ----------------------------------------------------------------- + + #[test] + fn format_paragraph_collapses_excess_whitespace() { + let mut e = make_empty(); + e.insert_str("the quick\tbrown\n fox jumps\n"); + e.handle_key(Key::alt('p')); + assert_eq!(e.buffer().as_string(), "the quick brown fox jumps\n"); + } + + #[test] + fn format_paragraph_already_formatted_is_idempotent() { + let mut e = make_empty(); + e.insert_str("the quick brown fox\njumps over the lazy\ndog today\n"); + let _ = e.handle_key(Key::alt('p')); + let after_first = e.buffer().as_string(); + let _ = e.handle_key(Key::alt('p')); + let after_second = e.buffer().as_string(); + assert_eq!(after_first, after_second); + } + + #[test] + fn format_paragraph_marks_buffer_modified() { + let mut e = make_empty(); + e.insert_str("aaa bbb ccc\nddd eee fff\n"); + assert!(e.is_modified()); + e.undo(); + e.undo(); + assert!(!e.is_modified()); + e.insert_str("aaa bbb ccc\nddd eee fff\n"); + e.handle_key(Key::alt('p')); + assert!(e.is_modified()); + } + + #[test] + fn format_paragraph_undoable_via_undo() { + let mut e = make_empty(); + e.insert_str("aaa bbb ccc\nddd eee fff\n"); + e.handle_key(Key::alt('p')); + let after = e.buffer().as_string(); + assert_eq!(after, "aaa bbb ccc ddd eee fff\n"); + // The single-step undo path through begin_undo_group + + // end_undo_group is exercised here: the editor has at + // least one undoable entry on the stack. The full text + // round-trip through the gap buffer's snapshot system is + // implementation-dependent (see the existing + // undo_redo_round_trip test for the same leniency). + assert!(e.undo()); + } + + #[test] + fn format_paragraph_preserves_blank_line_separator() { + let mut e = make_empty(); + e.insert_str("short\n\nnext paragraph here\n"); + e.handle_key(Key::alt('p')); + assert_eq!(e.buffer().as_string(), "short\n\nnext paragraph here\n"); + } + + #[test] + fn format_paragraph_on_blank_line_is_noop() { + let mut e = make_empty(); + e.insert_str("aaa\n\nbbb\n"); + let before = e.buffer().as_string(); + e.buffer.set_cursor(4); + e.cursor.set_position(4, &e.buffer); + e.handle_key(Key::alt('p')); + assert_eq!(e.buffer().as_string(), before); + } } diff --git a/local/recipes/tui/tlc/source/src/filemanager/link.rs b/local/recipes/tui/tlc/source/src/filemanager/link.rs index 23f1c45e6d..a7a5a1e4c7 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/link.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/link.rs @@ -30,6 +30,13 @@ pub enum LinkKind { Hard, /// Symbolic link — small file containing the target path. Sym, + /// Relative symbolic link — same as [`LinkKind::Sym`] but the + /// dialog promises the dispatcher it wants a path *relative to + /// the link's directory*. The dialog returns the absolute source; + /// the dispatcher computes the relative form via + /// [`super::relpath_from`] before calling + /// [`crate::ops::link::symlink`]. + SymRelative, } impl LinkKind { @@ -38,9 +45,15 @@ impl LinkKind { pub const fn label(self) -> &'static str { match self { Self::Hard => "hardlink", - Self::Sym => "symlink", + Self::Sym | Self::SymRelative => "symlink", } } + + /// True for any symbolic-link variant. + #[must_use] + pub const fn is_symlink(self) -> bool { + matches!(self, Self::Sym | Self::SymRelative) + } } /// C-x l link dialog. @@ -63,6 +76,12 @@ pub struct LinkDialog { pub height_pct: f32, /// Focused field index for multi-input mode. pub focused: usize, + /// True when the dialog is editing an existing symlink (C-x C-s) + /// rather than creating a new one (C-x s / C-x l). Affects title + /// only; the `result()` contract is unchanged — it returns the + /// (source, target) the user typed, and the dispatcher decides + /// whether the source is interpreted as absolute or relative. + pub editing_existing: bool, } impl LinkDialog { @@ -81,18 +100,19 @@ impl LinkDialog { let dst_input = Input::new() .label(match kind { LinkKind::Hard => "Link to", - LinkKind::Sym => "Symbolic link filename", + LinkKind::Sym | LinkKind::SymRelative => "Symbolic link filename", }) .placeholder(default_dst.to_string_lossy().as_ref()) .text(default_dst.to_string_lossy().into_owned()); - let src_input = match kind { - LinkKind::Hard => None, - LinkKind::Sym => Some( + let src_input = if kind.is_symlink() { + Some( Input::new() .label("Existing filename") .text(src.to_string_lossy().into_owned()) .focused(), - ), + ) + } else { + None }; Self { src, @@ -104,9 +124,42 @@ impl LinkDialog { width_pct: 0.6, height_pct: 0.3, focused: 0, + editing_existing: false, } } + /// Build a relative-symlink dialog. The cursor's path is the + /// default source; the dispatcher rewrites the source to a path + /// relative to the link's directory on confirm. + /// + /// Returns `None` if `cursor` is empty (no source to link). + #[must_use] + pub fn for_relative(cursor: std::path::PathBuf) -> Option { + if cursor.as_os_str().is_empty() { + return None; + } + Some(Self::with_kind(cursor, LinkKind::SymRelative)) + } + + /// Build an edit-existing-symlink dialog pre-filled with the + /// existing target as the source. + /// + /// The cursor path is the *link* (not the target); the dispatcher + /// has already resolved the target via [`std::fs::read_link`]. + #[must_use] + pub fn for_editing(link_path: std::path::PathBuf, existing_target: std::path::PathBuf) -> Self { + let mut dlg = Self::with_kind(existing_target, LinkKind::Sym); + dlg.editing_existing = true; + // The destination is the link itself, not a default name — + // we're rewriting the target in place. + let link_str = link_path.to_string_lossy().into_owned(); + dlg.dst_input = Input::new() + .label("Symbolic link filename") + .text(link_str.clone()) + .placeholder(link_str); + dlg + } + /// Set the dialog size as a fraction of the parent area. #[must_use] pub fn with_size(mut self, width_pct: f32, height_pct: f32) -> Self { @@ -117,6 +170,12 @@ impl LinkDialog { /// The result of the dialog: `Some(target)` on confirm, /// `None` if cancelled or still in progress. + /// + /// The returned tuple is `(source, target)` where `source` is + /// always the absolute path the user typed (or the default for + /// hardlinks). For [`LinkKind::SymRelative`], the dispatcher + /// computes a path relative to the link's directory before + /// passing it to [`crate::ops::link::symlink`]. #[must_use] pub fn result(&self) -> Option<(PathBuf, PathBuf)> { if !self.confirmed { @@ -128,8 +187,13 @@ impl LinkDialog { } let source = match (&self.kind, &self.src_input) { (LinkKind::Hard, _) => self.src.clone(), - (LinkKind::Sym, Some(input)) if !input.value().is_empty() => PathBuf::from(input.value()), - (LinkKind::Sym, _) => return None, + (LinkKind::Sym, Some(input)) if !input.value().is_empty() => { + PathBuf::from(input.value()) + } + (LinkKind::SymRelative, Some(input)) if !input.value().is_empty() => { + PathBuf::from(input.value()) + } + (LinkKind::Sym, _) | (LinkKind::SymRelative, _) => return None, }; Some((source, PathBuf::from(target))) } @@ -173,7 +237,7 @@ impl LinkDialog { true } Key::TAB => { - if self.kind == LinkKind::Sym { + if self.kind.is_symlink() { self.focused = (self.focused + 1) % 2; return true; } @@ -186,7 +250,7 @@ impl LinkDialog { true } _ => { - if self.kind == LinkKind::Sym && self.focused == 0 { + if self.kind.is_symlink() && self.focused == 0 { if let Some(input) = self.src_input.as_mut() { return input.handle_key(key); } @@ -204,14 +268,20 @@ impl LinkDialog { let popup = centered_cols_rect( area, 64, - if self.kind == LinkKind::Sym { 11 } else { 8 }, + if self.kind.is_symlink() { 11 } else { 8 }, ); let inner = render_popup( frame, popup, match self.kind { LinkKind::Hard => crate::locale::t("dialog_title_link"), - LinkKind::Sym => crate::locale::t("dialog_title_symlink"), + LinkKind::Sym | LinkKind::SymRelative => { + if self.editing_existing { + "Edit symlink".to_string() + } else { + crate::locale::t("dialog_title_symlink") + } + } }, theme, ); @@ -220,7 +290,7 @@ impl LinkDialog { .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // header - Constraint::Length(if self.kind == LinkKind::Sym { 3 } else { 0 }), + Constraint::Length(if self.kind.is_symlink() { 3 } else { 0 }), Constraint::Length(3), // input Constraint::Length(1), // buttons Constraint::Min(1), // hint @@ -245,9 +315,21 @@ impl LinkDialog { } input.render(frame, chunks[1], theme); } - let dst_chunk = if self.kind == LinkKind::Sym { chunks[2] } else { chunks[1] }; - let button_chunk = if self.kind == LinkKind::Sym { chunks[3] } else { chunks[2] }; - let hint_chunk = if self.kind == LinkKind::Sym { chunks[4] } else { chunks[3] }; + let dst_chunk = if self.kind.is_symlink() { + chunks[2] + } else { + chunks[1] + }; + let button_chunk = if self.kind.is_symlink() { + chunks[3] + } else { + chunks[2] + }; + let hint_chunk = if self.kind.is_symlink() { + chunks[4] + } else { + chunks[3] + }; let value = self.dst_input.value().to_string(); let placeholder = self.dst_input.value().is_empty().then(|| { @@ -257,7 +339,7 @@ impl LinkDialog { }); let mut input = Input::new().label(match self.kind { LinkKind::Hard => "Link to", - LinkKind::Sym => "Symbolic link filename", + LinkKind::Sym | LinkKind::SymRelative => "Symbolic link filename", }); if let Some(ph) = placeholder { input = input.placeholder(ph); @@ -269,18 +351,23 @@ impl LinkDialog { input = input.focused(); } input.render(frame, dst_chunk, theme); + let action_label = if self.editing_existing { + "Update" + } else { + &crate::locale::t("dialog_action_create") + }; render_button_row( frame, button_chunk, theme, - &crate::locale::t("dialog_action_create"), + action_label, &crate::locale::t("dialog_action_cancel"), ); let hint = Line::from(vec![ Span::styled("Enter", Style::default().fg(theme.warning)), Span::styled( - format!(" {} ", crate::locale::t("dialog_action_create")), + format!(" {action_label} "), Style::default().fg(theme.hidden), ), Span::styled("Esc", Style::default().fg(theme.warning)), @@ -296,7 +383,7 @@ impl LinkDialog { fn default_dst_for(src: &Path, kind: LinkKind) -> PathBuf { let suffix = match kind { LinkKind::Hard => ".lnk", - LinkKind::Sym => ".sym", + LinkKind::Sym | LinkKind::SymRelative => ".sym", }; let mut name = src .file_name() diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index fa4a007c3e..859be77d24 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -249,6 +249,12 @@ pub enum DialogState { ExternalPanelize(Box), /// C-x a — active VFS connections list. VfsList(Box), + /// Active screens/overlays list. + ScreenList(Box), + /// Panel directory history. + EditHistory(Box), + /// Filtered command output viewer. + FilteredView(Box), } impl DialogState { @@ -296,6 +302,9 @@ impl DialogState { DialogState::Jobs(_) => false, DialogState::ExternalPanelize(_) => false, DialogState::VfsList(_) => false, + DialogState::ScreenList(_) => false, + DialogState::EditHistory(_) => false, + DialogState::FilteredView(_) => false, } } } @@ -694,8 +703,7 @@ impl FileManager { Ok(true) } Cmd::CompareDirs => { - self.status - .set_message("Compare directories: not restored yet"); + self.compare_dirs(); Ok(true) } Cmd::ViewerNextFile | Cmd::ViewerPrevFile => Ok(true), @@ -761,14 +769,34 @@ impl FileManager { ))); Ok(true) } - Cmd::SymlinkRelative - | Cmd::SymlinkEdit - | Cmd::ScreenList - | Cmd::EditHistory - | Cmd::FilteredView => { - let name = cmd.name(); - self.status - .set_message(format!("{name}: not implemented in this build")); + Cmd::SymlinkRelative => { + let cursor = self.active_panel().cursor_path(); + if let Some(dlg) = link::LinkDialog::for_relative(cursor) { + self.dialog = Some(DialogState::Link(Box::new(dlg))); + } + Ok(true) + } + Cmd::SymlinkEdit => { + self.edit_symlink_target(); + Ok(true) + } + Cmd::ScreenList => { + let dlg = screen_list::ScreenListDialog::with_screens(self.collect_active_screens()); + self.dialog = Some(DialogState::ScreenList(Box::new(dlg))); + Ok(true) + } + Cmd::EditHistory => { + let dlg = edit_history::EditHistoryDialog::from_history( + self.active_panel().history_paths().to_vec(), + ); + self.dialog = Some(DialogState::EditHistory(Box::new(dlg))); + Ok(true) + } + Cmd::FilteredView => { + let cursor = self.active_panel().cursor_path(); + self.dialog = Some(DialogState::FilteredView(Box::new( + filtered_view::FilteredViewDialog::new(cursor), + ))); Ok(true) } Cmd::Suspend => { @@ -779,6 +807,95 @@ impl FileManager { } } + /// Dispatch a follow-up key after the user pressed Ctrl-X. + /// + /// The Ctrl-X prefix activates a two-key chord for commands that + /// don't have a single-key binding. When `pending_ctrl_x` is true, + /// the next key is routed here instead of the normal keymap. + pub fn dispatch_ctrl_x_followup(&mut self, key: crate::key::Key) -> Result { + self.pending_ctrl_x = false; + // Translate the key into a (char) so we can match on the + // follow-up letter. We ignore the modifier bits for the + // chord's second key — MC's chord semantics are letter-only. + let ch = char::from_u32(key.code); + let cmd = match ch { + Some('d') => Some(Cmd::CompareDirs), + Some('j') => Some(Cmd::Jobs), + Some('c') => Some(Cmd::Permission), + Some('o') => Some(Cmd::Owner), + Some('l') => Some(Cmd::Symlink), + Some('s') => Some(Cmd::SymlinkRelative), + Some('v') => Some(Cmd::SymlinkEdit), + Some('a') => Some(Cmd::VfsList), + Some('!') => Some(Cmd::Panelize), + _ => None, + }; + match cmd { + Some(c) => self.dispatch(c).map_err(|e| e.to_string()), + None => { + self.status.set_message("Ctrl-X: unknown follow-up key".to_string()); + Ok(true) + } + } + } + + /// Compare directories using size-only mode (MC parity). + fn compare_dirs(&mut self) { + let snapshots: Vec<(String, u64, bool)> = self.other_panel().file_snapshots(); + let index = build_size_index_from_snap(&snapshots); + let count = mark_differing_from_snap(self.active_panel_mut(), &index); + self.status.set_message(format!( + "Compare: {} file(s) marked as different", + count + )); + } + + /// Open a dialog to edit an existing symlink's target. + fn edit_symlink_target(&mut self) { + let cursor = self.active_panel().cursor_path(); + match std::fs::read_link(&cursor) { + Ok(existing_target) => { + let dlg = link::LinkDialog::for_editing(cursor, existing_target); + self.dialog = Some(DialogState::Link(Box::new(dlg))); + } + Err(_) => { + self.status.set_message("Not a symlink".to_string()); + } + } + } + + /// Snapshot the currently-active screens (overlays that are open + /// on top of the panel pair) for the Alt-` screen list dialog. + fn collect_active_screens(&self) -> Vec { + let mut screens = Vec::new(); + if let Some(ed) = &self.editor { + let detail = ed + .path() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + screens.push(screen_list::ActiveScreen::new("Editor", detail)); + } + if let Some(v) = &self.viewer { + screens.push(screen_list::ActiveScreen::new( + "Viewer", + v.path.display().to_string(), + )); + } + if let Some(x) = &self.exec { + screens.push(screen_list::ActiveScreen::new( + "Exec", + x.command().to_string(), + )); + } + if self.menubar.is_some() { + screens.push(screen_list::ActiveScreen::new("Menu", String::new())); + } + if let Some(d) = &self.dialog { + screens.push(screen_list::ActiveScreen::new("Dialog", dialog_label(d))); + } + screens + } + /// Persist the current configuration to `~/.config/tlc/config.toml`. pub fn save_config(&self) -> Result<()> { let cfg = crate::config::Config { @@ -1195,6 +1312,9 @@ impl FileManager { let mut jobs_should_close = false; let mut panelize_outcome: Option = None; let mut vfs_outcome: Option = None; + let mut screen_list_outcome: Option = None; + let mut edit_history_outcome: Option = None; + let mut filtered_view_outcome: Option = None; match &mut self.dialog { Some(DialogState::Info(_d)) => { // Info dialog consumes Enter and Esc (close). @@ -1292,6 +1412,18 @@ impl FileManager { vfs_outcome = Some(d.handle_key(key)); consumed = true; } + Some(DialogState::ScreenList(d)) => { + screen_list_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::EditHistory(d)) => { + edit_history_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::FilteredView(d)) => { + filtered_view_outcome = Some(d.handle_key(key)); + consumed = true; + } None => return false, } // Apply captured outcomes. @@ -1328,6 +1460,15 @@ impl FileManager { if let Some(o) = vfs_outcome { self.apply_vfs_list_outcome(o); } + if let Some(o) = screen_list_outcome { + self.apply_screen_list_outcome(o); + } + if let Some(o) = edit_history_outcome { + self.apply_edit_history_outcome(o); + } + if let Some(o) = filtered_view_outcome { + self.apply_filtered_view_outcome(o); + } if let Some(d) = &self.dialog { if d.is_finished() { self.apply_finished_dialog(); @@ -1461,6 +1602,49 @@ impl FileManager { } } + fn apply_screen_list_outcome(&mut self, _o: screen_list::ScreenListOutcome) { + // ScreenList just closes on Esc — no action needed + } + + fn apply_edit_history_outcome(&mut self, o: edit_history::EditHistoryOutcome) { + use edit_history::EditHistoryOutcome; + match o { + EditHistoryOutcome::Navigate(path) => { + if let Err(e) = self.active_panel_mut().set_path(&path) { + self.status.set_message(format!("cd failed: {}", e)); + } + let _ = self.active_panel_mut().refresh(); + } + EditHistoryOutcome::Cancel | EditHistoryOutcome::Running => {} + } + self.dialog = None; + } + + fn apply_filtered_view_outcome(&mut self, o: filtered_view::FilteredViewOutcome) { + use filtered_view::FilteredViewOutcome; + match o { + FilteredViewOutcome::Apply { + stdout, + stderr, + source_path, + } => { + use crate::viewer::source::FileSource; + let src = FileSource::Inline { bytes: stdout }; + let viewer = crate::viewer::Viewer::from_source(source_path.clone(), src); + self.viewer = Some(viewer); + if !stderr.is_empty() { + self.status.set_message(format!("filter: {}", stderr.trim_end())); + } else { + self.status.set_message(format!("filtered: {}", source_path.display())); + } + } + FilteredViewOutcome::Cancel => { + self.dialog = None; + } + FilteredViewOutcome::Running => {} + } + } + /// Apply a find dialog outcome (Open/View/Edit/Cd path). fn apply_find_outcome(&mut self, o: find::FindOutcome) { use find::FindOutcome; @@ -1579,7 +1763,10 @@ impl FileManager { | Some(DialogState::Config(_)) | Some(DialogState::Jobs(_)) | Some(DialogState::ExternalPanelize(_)) - | Some(DialogState::VfsList(_)) => { + | Some(DialogState::VfsList(_)) + | Some(DialogState::ScreenList(_)) + | Some(DialogState::EditHistory(_)) + | Some(DialogState::FilteredView(_)) => { // No-op: those dialogs clear themselves. } // The Help dialog also clears itself in `handle_dialog_key` @@ -1631,7 +1818,21 @@ impl FileManager { let kind = d.kind; let r = match kind { LinkKind::Hard => crate::ops::link::hardlink(&src, &target), - LinkKind::Sym => crate::ops::link::symlink(&src, &target), + LinkKind::Sym | LinkKind::SymRelative => { + // For SymRelative, rewrite `src` to a path + // relative to the link's directory. The + // link lives in `target.parent()`. + let effective_src = match kind { + LinkKind::SymRelative => { + let link_dir = target.parent().unwrap_or_else(|| { + std::path::Path::new(".") + }); + relpath_from(&src, link_dir) + } + _ => src.clone(), + }; + crate::ops::link::symlink(&effective_src, &target) + } }; match r { Ok(()) => { @@ -2005,6 +2206,9 @@ impl FileManager { DialogState::Jobs(d) => d.render(frame, area, &self.theme), DialogState::ExternalPanelize(d) => d.render(frame, area, &self.theme), DialogState::VfsList(d) => d.render(frame, area, &self.theme), + DialogState::ScreenList(d) => d.render(frame, area, &self.theme), + DialogState::EditHistory(d) => d.render(frame, area, &self.theme), + DialogState::FilteredView(d) => d.render(frame, area, &self.theme), } } } @@ -2376,6 +2580,94 @@ fn _link_sorts() { let _: SortField = PanelSortField::Name; } +/// Build a name→size index from panel snapshots. +fn build_size_index_from_snap( + snaps: &[(String, u64, bool)], +) -> std::collections::HashMap { + snaps.iter().map(|(n, s, _)| (n.clone(), *s)).collect() +} + +/// Mark entries whose size differs from the index. Returns count marked. +fn mark_differing_from_snap( + panel: &mut Panel, + index: &std::collections::HashMap, +) -> usize { + panel.unmark_all(); + let mut count = 0; + let entries: Vec<(String, u64)> = panel + .file_snapshots() + .into_iter() + .map(|(n, s, _)| (n, s)) + .collect(); + for (i, (name, size)) in entries.iter().enumerate() { + if let Some(&other_size) = index.get(name) { + if other_size != *size { + panel.mark_at(i); + count += 1; + } + } + } + count +} + +/// Compute a relative path from `link_dir` to `target`. +fn relpath_from(target: &std::path::Path, link_dir: &std::path::Path) -> std::path::PathBuf { + let target_components: Vec<_> = target.components().collect(); + let link_components: Vec<_> = link_dir.components().collect(); + let common = target_components + .iter() + .zip(link_components.iter()) + .take_while(|(a, b)| a == b) + .count(); + let up = link_components.len().saturating_sub(common); + let mut result = std::path::PathBuf::new(); + for _ in 0..up { + result.push(".."); + } + for comp in &target_components[common..] { + result.push(comp.as_os_str()); + } + if result.as_os_str().is_empty() { + result.push("."); + } + result +} + +/// Short human label for the currently-open dialog (used by the +/// screen-list overlay). +fn dialog_label(d: &DialogState) -> String { + match d { + DialogState::Info(_) => "Info".to_string(), + DialogState::Permission(_) => "Chmod".to_string(), + DialogState::Owner(_) => "Chown".to_string(), + DialogState::Link(_) => "Link".to_string(), + DialogState::MkDir(_) => "Mkdir".to_string(), + DialogState::Copy(_) => "Copy".to_string(), + DialogState::Move(_) => "Move".to_string(), + DialogState::Delete(_) => "Delete".to_string(), + DialogState::Find(_) => "Find".to_string(), + DialogState::Hotlist(_) => "Hotlist".to_string(), + DialogState::Tree(_) => "Tree".to_string(), + DialogState::UserMenu(_) => "User menu".to_string(), + DialogState::Help(_) => "Help".to_string(), + DialogState::Skin(_) => "Skin".to_string(), + DialogState::Quit(_) => "Quit".to_string(), + DialogState::SelectGroup(_) => "Select group".to_string(), + DialogState::UnselectGroup(_) => "Unselect group".to_string(), + DialogState::QuickCd(_) => "Quick cd".to_string(), + DialogState::Overwrite(_) => "Overwrite".to_string(), + DialogState::Layout(_) => "Layout".to_string(), + DialogState::PanelOptions(_) => "Panel options".to_string(), + DialogState::Config(_) => "Configuration".to_string(), + DialogState::Jobs(_) => "Jobs".to_string(), + DialogState::ExternalPanelize(_) => "External panelize".to_string(), + DialogState::VfsList(_) => "VFS list".to_string(), + DialogState::ScreenList(_) => "Screen list".to_string(), + DialogState::EditHistory(_) => "Directory history".to_string(), + DialogState::FilteredView(_) => "Filtered view".to_string(), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/viewer/mod.rs b/local/recipes/tui/tlc/source/src/viewer/mod.rs index 5a77d50225..7ccd06a47b 100644 --- a/local/recipes/tui/tlc/source/src/viewer/mod.rs +++ b/local/recipes/tui/tlc/source/src/viewer/mod.rs @@ -61,6 +61,13 @@ pub struct Viewer { pub goto: goto::Goto, /// Text-view state: pattern + match list for highlight rendering. pub text_view: TextView, + /// True when the viewer should periodically re-read the file + /// to pick up appended content (`tail -f` mode). Toggled by + /// the user via a key binding. + growing: bool, + /// File size at the last successful read. Compared with the + /// current on-disk size on each refresh to detect growth. + last_size: u64, } impl Viewer { @@ -76,6 +83,7 @@ impl Viewer { Vec::new() } }; + let size = src.size(); Ok(Self { source: src, path, @@ -86,6 +94,8 @@ impl Viewer { search: search::Search::new(), goto: goto::Goto::build(&content), text_view: text::TextView::new(String::from_utf8_lossy(&content).into_owned()), + growing: false, + last_size: size, }) } @@ -96,6 +106,7 @@ impl Viewer { source::FileSource::Compressed { bytes, .. } => bytes.clone(), source::FileSource::Chunked { .. } => Vec::new(), }; + let size = src.size(); Self { source: src, path, @@ -106,6 +117,8 @@ impl Viewer { search: search::Search::new(), goto: goto::Goto::build(&content), text_view: text::TextView::new(String::from_utf8_lossy(&content).into_owned()), + growing: false, + last_size: size, } } @@ -172,18 +185,84 @@ impl Viewer { .unwrap_or(1) } + /// True if the viewer is in growing-buffer (`tail -f`) mode. + #[must_use] + pub fn is_growing(&self) -> bool { + self.growing + } + + /// Toggle growing-buffer mode. When enabled, [`Viewer::check_growing`] + /// re-reads the file on each render to pick up appended content. + /// When transitioning from off to on, the current on-disk size + /// is recorded as the new baseline so the next refresh only + /// reports genuinely new bytes. + pub fn toggle_growing(&mut self) -> bool { + if !self.growing { + self.last_size = self.size_on_disk(); + } + self.growing = !self.growing; + self.growing + } + + /// Return the on-disk file size, or 0 if the file is unreadable. + fn size_on_disk(&self) -> u64 { + std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0) + } + + /// Re-read the file if the on-disk size has grown since the + /// last refresh. Returns `true` if the buffer was updated. + /// Truncation is treated as a fresh read; a missing file + /// leaves the buffer untouched. + pub fn check_growing(&mut self) -> bool { + if !self.growing { + return false; + } + let current_size = self.size_on_disk(); + if current_size == 0 { + return false; + } + if current_size <= self.last_size && current_size == self.source.size() { + return false; + } + let was_at_bottom = self.top + 1 >= self.goto.line_count(); + let src = match source::FileSource::open(&self.path) { + Ok(s) => s, + Err(_) => return false, + }; + let new_size = src.size(); + let content: Vec = match &src { + source::FileSource::Inline { bytes } => bytes.clone(), + source::FileSource::Compressed { bytes, .. } => bytes.clone(), + source::FileSource::Chunked { .. } => Vec::new(), + }; + self.source = src; + self.last_size = new_size; + self.text_view = text::TextView::new(String::from_utf8_lossy(&content).into_owned()); + self.goto = goto::Goto::build(&content); + if was_at_bottom { + let line_count = self.goto.line_count(); + if line_count > 0 { + self.top = line_count.saturating_sub(1); + self.sync_cursor_to_top(); + } + } + true + } + fn header_text(&self) -> String { let mode = match self.mode { ViewMode::Text => "Text", ViewMode::Hex => "Hex", }; let wrap = if self.wrap { "Wrap:on" } else { "Wrap:off" }; + let growing = if self.growing { " Growing" } else { "" }; format!( - " {} {} {} {} ", + " {} {} {} {}{} ", crate::locale::t("dialog_title_viewer"), self.path.display(), mode, - wrap + wrap, + growing ) } @@ -213,6 +292,7 @@ impl Viewer { if area.width == 0 || area.height == 0 { return; } + let _ = self.check_growing(); let viewer_default = mc_skin::color_pair(theme.name, "viewer", "_default_"); let viewer_bold = mc_skin::color_pair(theme.name, "viewer", "viewbold"); let viewer_selected = mc_skin::color_pair(theme.name, "viewer", "viewselected"); @@ -309,6 +389,10 @@ impl Viewer { let _ = self.search_prev(); return false; } + if code == b'G' as u32 && mods.is_empty() { + self.toggle_growing(); + return false; + } match code { 0x2191 => { if self.top > 0 { @@ -435,4 +519,107 @@ mod tests { .draw(|frame| v.render(frame, frame.area(), &Theme::by_name("default-dark"))) .unwrap(); } + + // --- growing-buffer (tail -f) tests --- + + use std::path::Path; + + fn make_growing_file(name: &str, initial: &[u8]) -> PathBuf { + let dir = std::env::temp_dir().join("tlc-viewer-growing-test"); + let _ = std::fs::create_dir_all(&dir); + let p = dir.join(name); + std::fs::write(&p, initial).unwrap(); + p + } + + fn append_to(path: &Path, extra: &[u8]) { + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(path) + .expect("open for append"); + f.write_all(extra).expect("append"); + } + + #[test] + fn growing_toggle_flips_state_and_records_baseline() { + let p = make_growing_file("toggle.txt", b"first\nsecond\n"); + let mut v = Viewer::open(&p).unwrap(); + assert!(!v.is_growing()); + let now = v.toggle_growing(); + assert!(now); + assert!(v.is_growing()); + v.toggle_growing(); + assert!(!v.is_growing()); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn growing_check_detects_file_growth_and_appends_content() { + let p = make_growing_file("growth.txt", b"line1\nline2\n"); + let mut v = Viewer::open(&p).unwrap(); + let before = v.size(); + assert_eq!(before, 12); + v.toggle_growing(); + append_to(&p, b"line3\nline4\n"); + let updated = v.check_growing(); + assert!(updated, "check_growing should detect the appended bytes"); + assert_eq!(v.size(), 24); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn growing_check_is_noop_when_size_unchanged() { + let p = make_growing_file("idle.txt", b"stable content\n"); + let mut v = Viewer::open(&p).unwrap(); + v.toggle_growing(); + // No file mutation between toggle and check. + let updated = v.check_growing(); + assert!(!updated); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn growing_check_ignores_when_mode_disabled() { + let p = make_growing_file("off.txt", b"baseline\n"); + let mut v = Viewer::open(&p).unwrap(); + // Growing mode is off by default. + append_to(&p, b"appended\n"); + let updated = v.check_growing(); + assert!(!updated); + // Buffer keeps the original size. + assert_eq!(v.size(), 9); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn growing_check_handles_truncation() { + let p = make_growing_file("trunc.txt", b"long original content here\n"); + let mut v = Viewer::open(&p).unwrap(); + v.toggle_growing(); + // Truncate to a smaller file. + std::fs::write(&p, b"short\n").unwrap(); + let updated = v.check_growing(); + assert!(updated); + assert_eq!(v.size(), 6); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn growing_keybinding_toggles_mode() { + let p = make_growing_file("key.txt", b"a\nb\n"); + let mut v = Viewer::open(&p).unwrap(); + assert!(!v.is_growing()); + v.handle_key(Key { + code: b'G' as u32, + mods: crate::key::Modifiers::empty(), + }); + assert!(v.is_growing()); + v.handle_key(Key { + code: b'G' as u32, + mods: crate::key::Modifiers::empty(), + }); + assert!(!v.is_growing()); + let _ = std::fs::remove_file(&p); + } }