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
This commit is contained in:
2026-06-19 06:49:15 +03:00
parent 9c5c5733cc
commit 59a4672acd
6 changed files with 1023 additions and 34 deletions
+16
View File
@@ -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() {
@@ -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<usize> = 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);
}
}
@@ -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);
}
}
@@ -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<Self> {
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()
@@ -249,6 +249,12 @@ pub enum DialogState {
ExternalPanelize(Box<external_panelize::ExternalPanelizeDialog>),
/// C-x a — active VFS connections list.
VfsList(Box<vfs_list::VfsListDialog>),
/// Active screens/overlays list.
ScreenList(Box<screen_list::ScreenListDialog>),
/// Panel directory history.
EditHistory(Box<edit_history::EditHistoryDialog>),
/// Filtered command output viewer.
FilteredView(Box<filtered_view::FilteredViewDialog>),
}
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<bool, String> {
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<screen_list::ActiveScreen> {
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<external_panelize::ExternalPanelizeOutcome> = None;
let mut vfs_outcome: Option<vfs_list::VfsListOutcome> = None;
let mut screen_list_outcome: Option<screen_list::ScreenListOutcome> = None;
let mut edit_history_outcome: Option<edit_history::EditHistoryOutcome> = None;
let mut filtered_view_outcome: Option<filtered_view::FilteredViewOutcome> = 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<String, u64> {
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<String, u64>,
) -> 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::*;
+189 -2
View File
@@ -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<u8> = 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);
}
}