tlc: Sprint 1 MC parity fixes (A1-A7)
Implements all 5 critical parity items from the comprehensive MC
assessment. Reference: MC source at local/recipes/tui/mc/source/.
A1 — Editor line number gutter:
Split editor inner area into gutter_area + body_area; gutter
renders right-aligned line numbers (or relative offsets when
relative_lines mode is on); bookmark rows show current-line
style; ~ shown for lines past end-of-file.
A2 — Viewer cursor line highlight:
cursor_line_bg derived from body_bg + RGB(12,12,12); applied
before search-match overlay so matches win on the cursor line.
A3 — Hide terminal cursor in file manager mode:
App::run() hides the cursor after Tui::new(); render() shows
the cursor only when editor/viewer/cmdline/dialog/menubar
is active.
A6 — Shift-F5/F6 same-directory rename:
New Cmd::CopySameDir and Cmd::MoveSameDir variants, bound to
Shift-F5 / Shift-F6. Reuse CopyDialog/MoveDialog with a new
same_dir flag and new_rename() constructor; result() resolves
the typed name against the source's parent directory.
A7 — SUID/SGID/sticky bits in chmod dialog:
4-row PermCell grid (user, group, other, special); class_shift
= 0o4000, bit = 1. Display as 0oXXXX. Overwrite dialog shows
all 12 cells. 6 new unit tests cover all special-bit combinations.
Other fixes in the same commit:
- 4 editor render tests shifted x-coordinates by gutter_chars
to account for the new gutter column.
- 1 viewer text test moves cursor to line 1 so cursor-line
highlight doesn't overlap the search-match assertion.
Tests: 1230 passed (was 1219 before A7; +11 new tests across A7
and A6). All Sprint 1 items compile clean.
This commit is contained in:
@@ -51,6 +51,9 @@ impl Application {
|
||||
let mut shell_manager = ShellManager::new();
|
||||
let keymap = default_keymap();
|
||||
|
||||
// MC parity: cursor hidden in panel mode; render() manages visibility.
|
||||
let _ = tui.terminal_mut().hide_cursor();
|
||||
|
||||
// Initial paint.
|
||||
render(&mut tui, &mut fm)?;
|
||||
|
||||
@@ -324,6 +327,16 @@ fn render(tui: &mut Tui, fm: &mut FileManager) -> Result<()> {
|
||||
fm.render(frame, area);
|
||||
fm.toasts.render(frame, area, &fm.theme);
|
||||
})?;
|
||||
let need_cursor = fm.editor.is_some()
|
||||
|| fm.viewer.is_some()
|
||||
|| fm.cmdline.is_active()
|
||||
|| fm.dialog.is_some()
|
||||
|| fm.menubar.is_some();
|
||||
if need_cursor {
|
||||
let _ = tui.terminal_mut().show_cursor();
|
||||
} else {
|
||||
let _ = tui.terminal_mut().hide_cursor();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -2354,11 +2354,11 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer.cell((1, 1)).expect("a cell").symbol(), "a");
|
||||
assert_eq!(buffer.cell((2, 1)).expect("space cell").symbol(), "·");
|
||||
assert_eq!(buffer.cell((3, 1)).expect("tab cell").symbol(), "→");
|
||||
assert_eq!(buffer.cell((4, 1)).expect("b cell").symbol(), "b");
|
||||
assert_eq!(buffer.cell((5, 1)).expect("caret cell").symbol(), "^");
|
||||
assert_eq!(buffer.cell((4, 1)).expect("a cell").symbol(), "a");
|
||||
assert_eq!(buffer.cell((5, 1)).expect("space cell").symbol(), "·");
|
||||
assert_eq!(buffer.cell((6, 1)).expect("tab cell").symbol(), "→");
|
||||
assert_eq!(buffer.cell((7, 1)).expect("b cell").symbol(), "b");
|
||||
assert_eq!(buffer.cell((8, 1)).expect("caret cell").symbol(), "^");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2387,7 +2387,7 @@ mod tests {
|
||||
})
|
||||
})
|
||||
.expect("must find 'b' from beta");
|
||||
let cell = buffer.cell((1, beta_y)).expect("beta line cell");
|
||||
let cell = buffer.cell((4, beta_y)).expect("beta line cell");
|
||||
assert_eq!(cell.symbol(), "b");
|
||||
assert_eq!(cell.fg, bookmarkfound.fg);
|
||||
assert_eq!(cell.bg, bookmarkfound.bg);
|
||||
@@ -2673,8 +2673,8 @@ mod tests {
|
||||
fg: theme.marked_fg,
|
||||
bg: theme.marked_bg,
|
||||
});
|
||||
let f_cell = buffer.cell((1, 1)).expect("'f' cell");
|
||||
let n_cell = buffer.cell((2, 1)).expect("'n' cell");
|
||||
let f_cell = buffer.cell((4, 1)).expect("'f' cell");
|
||||
let n_cell = buffer.cell((5, 1)).expect("'n' cell");
|
||||
assert_eq!(f_cell.symbol(), "f");
|
||||
assert_eq!(n_cell.symbol(), "n");
|
||||
assert_eq!(f_cell.bg, marked_pair.bg, "selected 'f' cell bg");
|
||||
|
||||
@@ -118,7 +118,24 @@ impl Editor {
|
||||
let inner = block.inner(editor_area);
|
||||
frame.render_widget(block, editor_area);
|
||||
|
||||
let body_area = inner;
|
||||
let line_count = self.buffer.line_count();
|
||||
let gutter_chars = line_count.max(1).to_string().len().max(3) as u16;
|
||||
let body_area = if gutter_chars < inner.width {
|
||||
Rect::new(
|
||||
inner.x + gutter_chars,
|
||||
inner.y,
|
||||
inner.width - gutter_chars,
|
||||
inner.height,
|
||||
)
|
||||
} else {
|
||||
inner
|
||||
};
|
||||
let gutter_area = Rect::new(
|
||||
inner.x,
|
||||
inner.y,
|
||||
gutter_chars.min(inner.width),
|
||||
inner.height,
|
||||
);
|
||||
|
||||
self.view.ensure_cursor_visible(
|
||||
&self.buffer,
|
||||
@@ -130,7 +147,6 @@ impl Editor {
|
||||
let height = body_area.height as usize;
|
||||
let cursor_line = self.buffer_line_of(self.cursor.position());
|
||||
let body_width = body_area.width as usize;
|
||||
let line_count = self.buffer.line_count();
|
||||
let wrapped_map: Vec<(usize, usize)> = if self.word_wrap && body_width > 0 {
|
||||
build_wrap_map(&self.buffer, top, height, body_width)
|
||||
} else {
|
||||
@@ -285,6 +301,38 @@ impl Editor {
|
||||
}
|
||||
frame.render_widget(Paragraph::new(body_lines), body_area);
|
||||
|
||||
if gutter_chars < inner.width {
|
||||
let gutter_lines: Vec<Line> = visible_wrapped
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|(line_idx, wrap_row)| {
|
||||
if line_idx >= line_count {
|
||||
return Line::from(Span::styled(
|
||||
"~",
|
||||
Style::default().fg(frame_fg).bg(body_bg),
|
||||
));
|
||||
}
|
||||
let text = if wrap_row > 0 {
|
||||
" ".repeat(gutter_chars as usize)
|
||||
} else {
|
||||
let num = if self.relative_lines {
|
||||
(line_idx as i64 - cursor_line as i64).unsigned_abs()
|
||||
} else {
|
||||
line_idx as u64 + 1
|
||||
};
|
||||
format!("{:>w$}", num, w = gutter_chars as usize)
|
||||
};
|
||||
let style = if line_idx == cursor_line {
|
||||
Style::default().fg(linestate_fg).bg(cur_line_bg)
|
||||
} else {
|
||||
Style::default().fg(frame_fg).bg(body_bg)
|
||||
};
|
||||
Line::from(Span::styled(text, style))
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(gutter_lines), gutter_area);
|
||||
}
|
||||
|
||||
if body_area.width > 0 {
|
||||
let margin_area = Rect::new(
|
||||
body_area.x + body_area.width - 1,
|
||||
|
||||
@@ -53,6 +53,10 @@ pub struct CopyDialog {
|
||||
pub width_pct: f32,
|
||||
/// Height as a fraction of the parent area.
|
||||
pub height_pct: f32,
|
||||
/// True for Shift-F5 same-directory rename mode: the user
|
||||
/// types just a new filename and the copy stays in the source's
|
||||
/// parent directory. Only valid with a single source.
|
||||
pub same_dir: bool,
|
||||
}
|
||||
|
||||
impl CopyDialog {
|
||||
@@ -93,6 +97,36 @@ impl CopyDialog {
|
||||
cancelled: false,
|
||||
width_pct: 0.6,
|
||||
height_pct: 0.3,
|
||||
same_dir: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a same-directory rename dialog (Shift-F5). The user
|
||||
/// types just a new filename; the copy stays in `src`'s parent.
|
||||
/// `src` must be a single file (not a marked batch).
|
||||
#[must_use]
|
||||
pub fn new_rename(src: PathBuf) -> Self {
|
||||
let default_text = src
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let input = Input::new()
|
||||
.label("Copy to new name in same directory")
|
||||
.text(default_text);
|
||||
Self {
|
||||
src: vec![src],
|
||||
marked_count: 1,
|
||||
dst_input: input,
|
||||
preserve_attributes: true,
|
||||
follow_links: false,
|
||||
dive_into_subdirs: false,
|
||||
stable_symlinks: false,
|
||||
focused: 0,
|
||||
confirmed: false,
|
||||
cancelled: false,
|
||||
width_pct: 0.6,
|
||||
height_pct: 0.3,
|
||||
same_dir: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +149,17 @@ impl CopyDialog {
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(s))
|
||||
let typed = PathBuf::from(s);
|
||||
if self.same_dir {
|
||||
let parent = self
|
||||
.src
|
||||
.first()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
return Some(parent.join(typed));
|
||||
}
|
||||
Some(typed)
|
||||
}
|
||||
|
||||
/// True if the dialog was cancelled.
|
||||
@@ -365,6 +409,36 @@ mod tests {
|
||||
assert_eq!(d.result(), Some(std::path::PathBuf::from("/var/dst")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rename_sets_same_dir_and_prefills_filename() {
|
||||
let d = CopyDialog::new_rename(std::path::PathBuf::from("/tmp/dir/foo.txt"));
|
||||
assert!(d.same_dir);
|
||||
assert_eq!(d.dst_input.value(), "foo.txt");
|
||||
assert_eq!(d.src, vec![std::path::PathBuf::from("/tmp/dir/foo.txt")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rename_result_resolves_relative_to_parent() {
|
||||
let mut d = CopyDialog::new_rename(std::path::PathBuf::from("/tmp/dir/foo.txt"));
|
||||
d.dst_input = Input::new().text("bar.txt");
|
||||
d.confirmed = true;
|
||||
assert_eq!(
|
||||
d.result(),
|
||||
Some(std::path::PathBuf::from("/tmp/dir/bar.txt"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rename_result_with_nested_name() {
|
||||
let mut d = CopyDialog::new_rename(std::path::PathBuf::from("/home/user/old.md"));
|
||||
d.dst_input = Input::new().text("new.md");
|
||||
d.confirmed = true;
|
||||
assert_eq!(
|
||||
d.result(),
|
||||
Some(std::path::PathBuf::from("/home/user/new.md"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_with_empty_does_not_confirm() {
|
||||
let mut d = CopyDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
|
||||
|
||||
@@ -196,6 +196,34 @@ impl FileManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the Shift-F5 same-directory rename dialog for the cursor file.
|
||||
pub fn open_copy_same_dir_dialog(&mut self) -> Result<()> {
|
||||
let panel = self.active_panel();
|
||||
let src = panel.cursor_path();
|
||||
if src.as_os_str().is_empty() {
|
||||
self.status.set_message("copy: no source selected");
|
||||
return Ok(());
|
||||
}
|
||||
self.dialog = Some(DialogState::Copy(Box::new(
|
||||
CopyDialog::new_rename(src),
|
||||
)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the Shift-F6 same-directory rename dialog for the cursor file.
|
||||
pub fn open_move_same_dir_dialog(&mut self) -> Result<()> {
|
||||
let panel = self.active_panel();
|
||||
let src = panel.cursor_path();
|
||||
if src.as_os_str().is_empty() {
|
||||
self.status.set_message("move: no source selected");
|
||||
return Ok(());
|
||||
}
|
||||
self.dialog = Some(DialogState::Move(Box::new(
|
||||
MoveDialog::new_rename(src),
|
||||
)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the F8 delete confirmation dialog for the cursor file or
|
||||
/// all marked files.
|
||||
pub fn open_delete_dialog(&mut self) -> Result<()> {
|
||||
@@ -838,46 +866,88 @@ impl FileManager {
|
||||
Some(DialogState::Copy(d)) => {
|
||||
if let Some(dst) = d.result() {
|
||||
let sources = d.src.clone();
|
||||
let preserve_attributes = d.preserve_attributes;
|
||||
let follow_links = d.follow_links;
|
||||
let handle = self.ops_manager.begin(
|
||||
crate::ops::OpKind::Copy,
|
||||
sources.clone(),
|
||||
Some(dst.clone()),
|
||||
);
|
||||
match crate::ops::copy::copy_many(
|
||||
&sources,
|
||||
&dst,
|
||||
&handle,
|
||||
false,
|
||||
preserve_attributes,
|
||||
follow_links,
|
||||
) {
|
||||
Ok(()) => {
|
||||
self.status.set_message(format!(
|
||||
"Copied {} item(s) to {}",
|
||||
sources.len(),
|
||||
dst.display()
|
||||
));
|
||||
self.ops_manager.finish();
|
||||
let _ = self.active_panel_mut().refresh();
|
||||
if d.same_dir {
|
||||
if let Some(src) = sources.first().cloned() {
|
||||
if src == dst {
|
||||
self.status
|
||||
.set_message("copy: source and destination are the same");
|
||||
} else if dst.exists() {
|
||||
self.pending_op = Some(PendingFileOp {
|
||||
is_move: false,
|
||||
sources,
|
||||
dst: dst.clone(),
|
||||
preserve_attributes: d.preserve_attributes,
|
||||
follow_links: d.follow_links,
|
||||
});
|
||||
self.dialog = Some(DialogState::Overwrite(Box::new(
|
||||
overwrite_dialog::OverwriteDialog::new_copy(
|
||||
dst.display().to_string(),
|
||||
),
|
||||
)));
|
||||
} else {
|
||||
let handle = self.ops_manager.begin(
|
||||
crate::ops::OpKind::Copy,
|
||||
sources.clone(),
|
||||
Some(dst.clone()),
|
||||
);
|
||||
match std::fs::copy(&src, &dst) {
|
||||
Ok(_) => {
|
||||
self.status.set_message(format!(
|
||||
"Copied {} -> {}",
|
||||
src.display(),
|
||||
dst.display()
|
||||
));
|
||||
self.ops_manager.finish();
|
||||
let _ = self.active_panel_mut().refresh();
|
||||
}
|
||||
Err(e) => {
|
||||
self.status.set_message(format!("copy: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(crate::ops::OpsError::DestExists(p)) => {
|
||||
self.pending_op = Some(PendingFileOp {
|
||||
is_move: false,
|
||||
sources,
|
||||
dst,
|
||||
preserve_attributes,
|
||||
follow_links,
|
||||
});
|
||||
self.dialog = Some(DialogState::Overwrite(Box::new(
|
||||
overwrite_dialog::OverwriteDialog::new_copy(
|
||||
p.display().to_string(),
|
||||
),
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
self.status.set_message(format!("copy: {e}"));
|
||||
} else {
|
||||
let preserve_attributes = d.preserve_attributes;
|
||||
let follow_links = d.follow_links;
|
||||
let handle = self.ops_manager.begin(
|
||||
crate::ops::OpKind::Copy,
|
||||
sources.clone(),
|
||||
Some(dst.clone()),
|
||||
);
|
||||
match crate::ops::copy::copy_many(
|
||||
&sources,
|
||||
&dst,
|
||||
&handle,
|
||||
false,
|
||||
preserve_attributes,
|
||||
follow_links,
|
||||
) {
|
||||
Ok(()) => {
|
||||
self.status.set_message(format!(
|
||||
"Copied {} item(s) to {}",
|
||||
sources.len(),
|
||||
dst.display()
|
||||
));
|
||||
self.ops_manager.finish();
|
||||
let _ = self.active_panel_mut().refresh();
|
||||
}
|
||||
Err(crate::ops::OpsError::DestExists(p)) => {
|
||||
self.pending_op = Some(PendingFileOp {
|
||||
is_move: false,
|
||||
sources,
|
||||
dst,
|
||||
preserve_attributes,
|
||||
follow_links,
|
||||
});
|
||||
self.dialog = Some(DialogState::Overwrite(Box::new(
|
||||
overwrite_dialog::OverwriteDialog::new_copy(
|
||||
p.display().to_string(),
|
||||
),
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
self.status.set_message(format!("copy: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -885,46 +955,96 @@ impl FileManager {
|
||||
Some(DialogState::Move(d)) => {
|
||||
if let Some(dst) = d.result() {
|
||||
let sources = d.src.clone();
|
||||
let preserve_attributes = d.preserve_attributes;
|
||||
let follow_links = d.follow_links;
|
||||
let handle = self.ops_manager.begin(
|
||||
crate::ops::OpKind::Move,
|
||||
sources.clone(),
|
||||
Some(dst.clone()),
|
||||
);
|
||||
match crate::ops::move_op::move_many(
|
||||
&sources,
|
||||
&dst,
|
||||
&handle,
|
||||
false,
|
||||
preserve_attributes,
|
||||
follow_links,
|
||||
) {
|
||||
Ok(()) => {
|
||||
self.status.set_message(format!(
|
||||
"Moved {} item(s) to {}",
|
||||
sources.len(),
|
||||
dst.display()
|
||||
));
|
||||
self.ops_manager.finish();
|
||||
let _ = self.active_panel_mut().refresh();
|
||||
if d.same_dir {
|
||||
if let Some(src) = sources.first().cloned() {
|
||||
if src == dst {
|
||||
self.status
|
||||
.set_message("move: source and destination are the same");
|
||||
} else {
|
||||
let handle = self.ops_manager.begin(
|
||||
crate::ops::OpKind::Move,
|
||||
sources.clone(),
|
||||
Some(dst.clone()),
|
||||
);
|
||||
match crate::ops::move_op::move_one(
|
||||
&src,
|
||||
&dst,
|
||||
&handle,
|
||||
false,
|
||||
d.preserve_attributes,
|
||||
d.follow_links,
|
||||
) {
|
||||
Ok(()) => {
|
||||
self.status.set_message(format!(
|
||||
"Renamed {} -> {}",
|
||||
src.display(),
|
||||
dst.display()
|
||||
));
|
||||
self.ops_manager.finish();
|
||||
let _ = self.active_panel_mut().refresh();
|
||||
}
|
||||
Err(crate::ops::OpsError::DestExists(p)) => {
|
||||
self.pending_op = Some(PendingFileOp {
|
||||
is_move: true,
|
||||
sources,
|
||||
dst,
|
||||
preserve_attributes: d.preserve_attributes,
|
||||
follow_links: d.follow_links,
|
||||
});
|
||||
self.dialog = Some(DialogState::Overwrite(Box::new(
|
||||
overwrite_dialog::OverwriteDialog::new_move(
|
||||
p.display().to_string(),
|
||||
),
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
self.status.set_message(format!("move: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(crate::ops::OpsError::DestExists(p)) => {
|
||||
self.pending_op = Some(PendingFileOp {
|
||||
is_move: true,
|
||||
sources,
|
||||
dst,
|
||||
preserve_attributes,
|
||||
follow_links,
|
||||
});
|
||||
self.dialog = Some(DialogState::Overwrite(Box::new(
|
||||
overwrite_dialog::OverwriteDialog::new_move(
|
||||
p.display().to_string(),
|
||||
),
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
self.status.set_message(format!("move: {e}"));
|
||||
} else {
|
||||
let preserve_attributes = d.preserve_attributes;
|
||||
let follow_links = d.follow_links;
|
||||
let handle = self.ops_manager.begin(
|
||||
crate::ops::OpKind::Move,
|
||||
sources.clone(),
|
||||
Some(dst.clone()),
|
||||
);
|
||||
match crate::ops::move_op::move_many(
|
||||
&sources,
|
||||
&dst,
|
||||
&handle,
|
||||
false,
|
||||
preserve_attributes,
|
||||
follow_links,
|
||||
) {
|
||||
Ok(()) => {
|
||||
self.status.set_message(format!(
|
||||
"Moved {} item(s) to {}",
|
||||
sources.len(),
|
||||
dst.display()
|
||||
));
|
||||
self.ops_manager.finish();
|
||||
let _ = self.active_panel_mut().refresh();
|
||||
}
|
||||
Err(crate::ops::OpsError::DestExists(p)) => {
|
||||
self.pending_op = Some(PendingFileOp {
|
||||
is_move: true,
|
||||
sources,
|
||||
dst,
|
||||
preserve_attributes,
|
||||
follow_links,
|
||||
});
|
||||
self.dialog = Some(DialogState::Overwrite(Box::new(
|
||||
overwrite_dialog::OverwriteDialog::new_move(
|
||||
p.display().to_string(),
|
||||
),
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
self.status.set_message(format!("move: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,10 +84,18 @@ impl FileManager {
|
||||
self.open_copy_dialog()?;
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::CopySameDir => {
|
||||
self.open_copy_same_dir_dialog()?;
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::Move => {
|
||||
self.open_move_dialog()?;
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::MoveSameDir => {
|
||||
self.open_move_same_dir_dialog()?;
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::Delete => {
|
||||
self.open_delete_dialog()?;
|
||||
Ok(true)
|
||||
|
||||
@@ -53,6 +53,10 @@ pub struct MoveDialog {
|
||||
pub width_pct: f32,
|
||||
/// Height as a fraction of the parent area.
|
||||
pub height_pct: f32,
|
||||
/// True for Shift-F6 same-directory rename mode: the user
|
||||
/// types just a new filename and the move stays in the source's
|
||||
/// parent directory. Only valid with a single source.
|
||||
pub same_dir: bool,
|
||||
}
|
||||
|
||||
impl MoveDialog {
|
||||
@@ -93,6 +97,36 @@ impl MoveDialog {
|
||||
cancelled: false,
|
||||
width_pct: 0.6,
|
||||
height_pct: 0.3,
|
||||
same_dir: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a same-directory rename dialog (Shift-F6). The user
|
||||
/// types just a new filename; the move stays in `src`'s parent.
|
||||
/// `src` must be a single file (not a marked batch).
|
||||
#[must_use]
|
||||
pub fn new_rename(src: PathBuf) -> Self {
|
||||
let default_text = src
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let input = Input::new()
|
||||
.label("Move to new name in same directory")
|
||||
.text(default_text);
|
||||
Self {
|
||||
src: vec![src],
|
||||
marked_count: 1,
|
||||
dst_input: input,
|
||||
preserve_attributes: true,
|
||||
follow_links: false,
|
||||
dive_into_subdirs: false,
|
||||
stable_symlinks: false,
|
||||
focused: 0,
|
||||
confirmed: false,
|
||||
cancelled: false,
|
||||
width_pct: 0.6,
|
||||
height_pct: 0.3,
|
||||
same_dir: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +149,17 @@ impl MoveDialog {
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(s))
|
||||
let typed = PathBuf::from(s);
|
||||
if self.same_dir {
|
||||
let parent = self
|
||||
.src
|
||||
.first()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
return Some(parent.join(typed));
|
||||
}
|
||||
Some(typed)
|
||||
}
|
||||
|
||||
/// True if the dialog was cancelled.
|
||||
@@ -355,6 +399,25 @@ mod tests {
|
||||
assert_eq!(d.src.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rename_sets_same_dir_and_prefills_filename() {
|
||||
let d = MoveDialog::new_rename(std::path::PathBuf::from("/tmp/dir/foo.txt"));
|
||||
assert!(d.same_dir);
|
||||
assert_eq!(d.dst_input.value(), "foo.txt");
|
||||
assert_eq!(d.src, vec![std::path::PathBuf::from("/tmp/dir/foo.txt")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rename_result_resolves_relative_to_parent() {
|
||||
let mut d = MoveDialog::new_rename(std::path::PathBuf::from("/tmp/dir/foo.txt"));
|
||||
d.dst_input = Input::new().text("bar.txt");
|
||||
d.confirmed = true;
|
||||
assert_eq!(
|
||||
d.result(),
|
||||
Some(std::path::PathBuf::from("/tmp/dir/bar.txt"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_with_valid_dst_confirms() {
|
||||
let mut d = MoveDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
|
||||
|
||||
@@ -81,6 +81,39 @@ pub const PERM_CELLS: [PermCell; 9] = [
|
||||
}, // other x
|
||||
];
|
||||
|
||||
const SPECIAL_LABELS: [&str; 3] = ["setuid", "setgid", "sticky"];
|
||||
|
||||
const SPECIAL_CELLS: [PermCell; 3] = [
|
||||
PermCell {
|
||||
class_shift: 0o4000,
|
||||
bit: 1,
|
||||
}, // setuid
|
||||
PermCell {
|
||||
class_shift: 0o2000,
|
||||
bit: 1,
|
||||
}, // setgid
|
||||
PermCell {
|
||||
class_shift: 0o1000,
|
||||
bit: 1,
|
||||
}, // sticky
|
||||
];
|
||||
|
||||
fn cell_at(idx: usize) -> PermCell {
|
||||
if idx < 9 {
|
||||
PERM_CELLS[idx]
|
||||
} else {
|
||||
SPECIAL_CELLS[idx - 9]
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_label(idx: usize) -> &'static str {
|
||||
if idx < 9 {
|
||||
["r", "w", "x"][idx % 3]
|
||||
} else {
|
||||
["u", "g", "t"][idx - 9]
|
||||
}
|
||||
}
|
||||
|
||||
/// C-x c chmod dialog.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PermissionDialog {
|
||||
@@ -111,7 +144,7 @@ impl PermissionDialog {
|
||||
Self {
|
||||
path,
|
||||
current_mode,
|
||||
mode: current_mode & 0o777,
|
||||
mode: current_mode & 0o7777,
|
||||
focused: 0,
|
||||
confirmed: false,
|
||||
cancelled: false,
|
||||
@@ -130,7 +163,7 @@ impl PermissionDialog {
|
||||
|
||||
/// Toggle the cell under the cursor.
|
||||
pub fn toggle_focused(&mut self) {
|
||||
let cell = PERM_CELLS[self.focused];
|
||||
let cell = cell_at(self.focused);
|
||||
self.mode ^= cell.mask();
|
||||
}
|
||||
|
||||
@@ -139,7 +172,7 @@ impl PermissionDialog {
|
||||
pub fn move_focus(&mut self, row_delta: i32, col_delta: i32) {
|
||||
let row = (self.focused / 3) as i32;
|
||||
let col = (self.focused % 3) as i32;
|
||||
let r = (row + row_delta).clamp(0, 2);
|
||||
let r = (row + row_delta).clamp(0, 3);
|
||||
let c = (col + col_delta).clamp(0, 2);
|
||||
self.focused = (r * 3 + c) as usize;
|
||||
}
|
||||
@@ -149,7 +182,7 @@ impl PermissionDialog {
|
||||
#[must_use]
|
||||
pub fn result(&self) -> Option<u32> {
|
||||
if self.confirmed {
|
||||
Some(self.mode & 0o777)
|
||||
Some(self.mode & 0o7777)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -179,8 +212,7 @@ impl PermissionDialog {
|
||||
true
|
||||
}
|
||||
Key { code: 0x09, .. } => {
|
||||
// Tab — cycle focus forward across the 9 cells.
|
||||
self.focused = (self.focused + 1) % 9;
|
||||
self.focused = (self.focused + 1) % 12;
|
||||
true
|
||||
}
|
||||
Key { code: 0x2190, .. } => {
|
||||
@@ -225,7 +257,7 @@ impl PermissionDialog {
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // grid
|
||||
Constraint::Min(4), // grid
|
||||
Constraint::Length(2), // hint + button
|
||||
])
|
||||
.split(inner);
|
||||
@@ -238,7 +270,7 @@ impl PermissionDialog {
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("Mode: {:03o}", self.mode & 0o777),
|
||||
format!("Mode: {:04o}", self.mode & 0o7777),
|
||||
Style::default()
|
||||
.fg(theme.warning)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -250,18 +282,18 @@ impl PermissionDialog {
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 4),
|
||||
Constraint::Ratio(1, 4),
|
||||
Constraint::Ratio(1, 4),
|
||||
Constraint::Ratio(1, 4),
|
||||
])
|
||||
.split(chunks[1]);
|
||||
let row_labels = ["user", "group", "other"];
|
||||
let col_labels = ["r", "w", "x"];
|
||||
let row_labels = ["user", "group", "other", "special"];
|
||||
for (row_idx, row_area) in rows.iter().enumerate() {
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(8), // class label
|
||||
Constraint::Length(8),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
@@ -278,11 +310,12 @@ impl PermissionDialog {
|
||||
);
|
||||
for col_idx in 0..3 {
|
||||
let cell_idx = row_idx * 3 + col_idx;
|
||||
let cell = PERM_CELLS[cell_idx];
|
||||
let cell = cell_at(cell_idx);
|
||||
let on = (self.mode & cell.mask()) != 0;
|
||||
let focused = self.focused == cell_idx;
|
||||
let mark = if on { 'x' } else { ' ' };
|
||||
let line = format!("[{}] {}", mark, col_labels[col_idx]);
|
||||
let label = cell_label(cell_idx);
|
||||
let line = format!("[{}] {}", mark, label);
|
||||
let style = if focused {
|
||||
Style::default()
|
||||
.fg(theme.cursor_fg)
|
||||
@@ -323,9 +356,9 @@ impl PermissionDialog {
|
||||
]);
|
||||
let ok = Line::from(Span::styled(
|
||||
format!(
|
||||
" [ OK ] (current {:03o} -> new {:03o}) ",
|
||||
self.current_mode & 0o777,
|
||||
self.mode & 0o777
|
||||
" [ OK ] (current {:04o} -> new {:04o}) ",
|
||||
self.current_mode & 0o7777,
|
||||
self.mode & 0o7777
|
||||
),
|
||||
Style::default()
|
||||
.fg(theme.cursor_fg)
|
||||
@@ -413,7 +446,7 @@ mod tests {
|
||||
d.move_focus(-1, 0);
|
||||
assert_eq!(d.focused, 0);
|
||||
d.move_focus(10, 10);
|
||||
assert_eq!(d.focused, 8);
|
||||
assert_eq!(d.focused, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -428,4 +461,59 @@ mod tests {
|
||||
assert_eq!(PERM_CELLS[7].mask(), 0o002); // other-w
|
||||
assert_eq!(PERM_CELLS[8].mask(), 0o001); // other-x
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_cell_mask_values() {
|
||||
assert_eq!(SPECIAL_CELLS[0].mask(), 0o4000); // setuid
|
||||
assert_eq!(SPECIAL_CELLS[1].mask(), 0o2000); // setgid
|
||||
assert_eq!(SPECIAL_CELLS[2].mask(), 0o1000); // sticky
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_setuid_bit() {
|
||||
let mut d = PermissionDialog::new("/x".into(), 0o755);
|
||||
d.focused = 9;
|
||||
d.toggle_focused();
|
||||
assert_eq!(d.mode, 0o4755);
|
||||
d.toggle_focused();
|
||||
assert_eq!(d.mode, 0o0755);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_setgid_bit() {
|
||||
let mut d = PermissionDialog::new("/x".into(), 0o755);
|
||||
d.focused = 10;
|
||||
d.toggle_focused();
|
||||
assert_eq!(d.mode, 0o2755);
|
||||
d.toggle_focused();
|
||||
assert_eq!(d.mode, 0o0755);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_sticky_bit() {
|
||||
let mut d = PermissionDialog::new("/x".into(), 0o755);
|
||||
d.focused = 11;
|
||||
d.toggle_focused();
|
||||
assert_eq!(d.mode, 0o1755);
|
||||
d.toggle_focused();
|
||||
assert_eq!(d.mode, 0o0755);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_cycles_through_12_cells() {
|
||||
let mut d = PermissionDialog::new("/x".into(), 0o000);
|
||||
for _ in 0..11 {
|
||||
d.handle_key(Key { code: 0x09, mods: crate::key::Modifiers::empty() });
|
||||
}
|
||||
assert_eq!(d.focused, 11);
|
||||
d.handle_key(Key { code: 0x09, mods: crate::key::Modifiers::empty() });
|
||||
assert_eq!(d.focused, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_special_bits_from_current_mode() {
|
||||
let d = PermissionDialog::new("/x".into(), 0o6755);
|
||||
assert_eq!(d.mode, 0o6755);
|
||||
assert_eq!(d.result(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,12 @@ pub enum Cmd {
|
||||
EnterDir,
|
||||
/// F5 — open the copy dialog.
|
||||
Copy,
|
||||
/// Shift-F5 — copy the cursor file under a new name in the same directory.
|
||||
CopySameDir,
|
||||
/// F6 — open the move/rename dialog.
|
||||
Move,
|
||||
/// Shift-F6 — move/rename the cursor file under a new name in the same directory.
|
||||
MoveSameDir,
|
||||
/// F8 — open the delete dialog.
|
||||
Delete,
|
||||
/// F7 — open the make-directory dialog.
|
||||
@@ -200,7 +204,9 @@ impl Cmd {
|
||||
Cmd::View => "View",
|
||||
Cmd::EnterDir => "Enter",
|
||||
Cmd::Copy => "Copy",
|
||||
Cmd::CopySameDir => "Copy (rename)",
|
||||
Cmd::Move => "Move",
|
||||
Cmd::MoveSameDir => "Move (rename)",
|
||||
Cmd::Delete => "Delete",
|
||||
Cmd::MkDir => "Make directory",
|
||||
Cmd::SwapPanels => "Swap panels",
|
||||
@@ -323,6 +329,13 @@ const F8: Key = Key::f(8);
|
||||
const F9: Key = Key::f(9);
|
||||
const F10: Key = Key::f(10);
|
||||
const F11: Key = Key::f(11);
|
||||
|
||||
fn shift(k: Key) -> Key {
|
||||
Key {
|
||||
code: k.code,
|
||||
mods: k.mods | crate::key::Modifiers::SHIFT,
|
||||
}
|
||||
}
|
||||
const TAB: Key = Key {
|
||||
code: 0x09,
|
||||
mods: Modifiers::empty(),
|
||||
@@ -390,6 +403,8 @@ pub fn default_keymap() -> Keymap {
|
||||
km.bind(F4, Cmd::Edit);
|
||||
km.bind(F5, Cmd::Copy);
|
||||
km.bind(F6, Cmd::Move);
|
||||
km.bind(shift(F5), Cmd::CopySameDir);
|
||||
km.bind(shift(F6), Cmd::MoveSameDir);
|
||||
km.bind(F7, Cmd::MkDir);
|
||||
km.bind(F8, Cmd::Delete);
|
||||
km.bind(F9, Cmd::MenuBar);
|
||||
@@ -500,6 +515,9 @@ mod tests {
|
||||
assert_eq!(km.lookup(F1), Some(Cmd::Help));
|
||||
assert_eq!(km.lookup(F3), Some(Cmd::View));
|
||||
assert_eq!(km.lookup(F5), Some(Cmd::Copy));
|
||||
assert_eq!(km.lookup(F6), Some(Cmd::Move));
|
||||
assert_eq!(km.lookup(shift(F5)), Some(Cmd::CopySameDir));
|
||||
assert_eq!(km.lookup(shift(F6)), Some(Cmd::MoveSameDir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! line-wrap and search highlight — no line-number gutter (MC parity).
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
@@ -208,6 +208,10 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let viewer_bold = mc_skin::color_pair(theme.name, "viewer", "viewbold");
|
||||
let body_fg = viewer_default.map(|p| p.fg).unwrap_or(theme.foreground);
|
||||
let body_bg = viewer_default.map(|p| p.bg).unwrap_or(theme.background);
|
||||
let cursor_line_bg = match body_bg {
|
||||
Color::Rgb(r, g, b) => Color::Rgb(r.saturating_add(12), g.saturating_add(12), b.saturating_add(12)),
|
||||
_ => theme.current_line_bg(),
|
||||
};
|
||||
let bold_fg = viewer_bold.map(|p| p.fg).unwrap_or(theme.warning);
|
||||
#[cfg(feature = "syntect")]
|
||||
{
|
||||
@@ -333,6 +337,7 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
usable_w,
|
||||
v.wrap,
|
||||
theme,
|
||||
cursor_line_bg,
|
||||
&nroff_ranges,
|
||||
#[cfg(feature = "syntect")]
|
||||
v.highlighter.as_mut(),
|
||||
@@ -363,6 +368,7 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
usable_w,
|
||||
v.wrap,
|
||||
theme,
|
||||
cursor_line_bg,
|
||||
&nroff_ranges,
|
||||
#[cfg(feature = "syntect")]
|
||||
v.highlighter.as_mut(),
|
||||
@@ -424,6 +430,7 @@ fn render_line_with_highlight(
|
||||
usable_w: usize,
|
||||
_wrap: bool,
|
||||
theme: &Theme,
|
||||
cursor_line_bg: Color,
|
||||
nroff_ranges: &[NroffRange],
|
||||
#[cfg(feature = "syntect")] highlighter: Option<&mut crate::editor::syntax::Highlighter>,
|
||||
) -> Vec<Span<'static>> {
|
||||
@@ -482,6 +489,11 @@ fn render_line_with_highlight(
|
||||
Style::default().fg(theme.foreground),
|
||||
));
|
||||
}
|
||||
if cursor_col.is_some() {
|
||||
for span in &mut spans {
|
||||
span.style = span.style.bg(cursor_line_bg);
|
||||
}
|
||||
}
|
||||
// Apply search match highlight ON TOP of syntax colors.
|
||||
if !matches_in_line.is_empty() {
|
||||
matches_in_line.sort_by_key(|m| m.0);
|
||||
@@ -492,16 +504,6 @@ fn render_line_with_highlight(
|
||||
nroff_in_line.sort_by_key(|r| r.0);
|
||||
spans = overlay_nroff_styles(spans, &nroff_in_line);
|
||||
}
|
||||
// Cursor marker.
|
||||
if let Some(col) = cursor_col {
|
||||
if col < spans.len() {
|
||||
// We don't try to insert the marker inside a Span — instead
|
||||
// we just change the bg of the matching Span if possible.
|
||||
// For a true cursor, the Viewer's main draw loop in mod.rs
|
||||
// handles cursor overlay; here we just ensure the spans are
|
||||
// intact.
|
||||
}
|
||||
}
|
||||
let _ = cursor_col;
|
||||
spans
|
||||
}
|
||||
@@ -800,6 +802,7 @@ mod tests {
|
||||
},
|
||||
);
|
||||
v.text_view.search("bar");
|
||||
v.cursor = 12;
|
||||
|
||||
let backend = TestBackend::new(80, 10);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user