Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Added ZIP archive creation for the focused item or current selection, with a configurable `create_archive` (`C`) shortcut, editable archive name, background progress, and cancellation. ([#215])
- Added a show/hide control to encrypted archive password prompts.

### Changed

Expand Down
29 changes: 29 additions & 0 deletions src/app/actions/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,36 @@ impl App {
archive_path,
input: String::new(),
cursor_col: 0,
visible: false,
error,
});
self.status.clear();
}

pub fn archive_password_is_visible(&self) -> bool {
self.overlays
.archive_password
.as_ref()
.is_some_and(|overlay| overlay.visible)
}

pub(in crate::app) fn toggle_archive_password_visibility(&mut self) {
if let Some(overlay) = &mut self.overlays.archive_password {
overlay.visible = !overlay.visible;
}
}

pub(in crate::app) fn handle_archive_password_key(&mut self, key: KeyEvent) -> Result<()> {
if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c')) {
self.overlays.archive_password = None;
return Ok(());
}

if key.modifiers == KeyModifiers::ALT && matches!(key.code, KeyCode::Char('v' | 'V')) {
self.toggle_archive_password_visibility();
return Ok(());
}

match key.code {
KeyCode::Esc => {
self.overlays.archive_password = None;
Expand Down Expand Up @@ -235,6 +254,16 @@ impl App {
mouse: MouseEvent,
) -> Result<()> {
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
if self
.input
.frame_state
.archive_password_visibility_btn
.is_some_and(|btn| rect_contains(btn, mouse.column, mouse.row))
{
self.toggle_archive_password_visibility();
return Ok(());
}

let inside = self
.input
.frame_state
Expand Down
32 changes: 32 additions & 0 deletions src/app/input/tests/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,38 @@ fn e_prompts_and_retries_encrypted_zip_archive() {
fs::remove_dir_all(root).expect("failed to remove temp root");
}

#[test]
fn archive_password_visibility_can_be_toggled() {
let root = temp_path("archive-password-visibility");
fs::create_dir_all(&root).expect("failed to create temp root");
let archive = root.join("sample.zip");
let password = archive_test_password(&root);
write_encrypted_zip_entries(&archive, &password, &[("file.txt", b"hello")]);

let mut app = App::new_at(root.clone()).expect("failed to create app");
wait_for_directory_load(&mut app);

app.handle_event(Event::Key(KeyEvent::from(KeyCode::Char('e'))))
.expect("e should start archive extraction");
wait_for_archive_password_prompt(&mut app);

assert!(!app.archive_password_is_visible());
app.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('v'),
KeyModifiers::ALT,
)))
.expect("visibility binding should be handled");
assert!(app.archive_password_is_visible());
app.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('v'),
KeyModifiers::ALT,
)))
.expect("visibility binding should toggle back");
assert!(!app.archive_password_is_visible());

fs::remove_dir_all(root).expect("failed to remove temp root");
}

#[test]
fn e_reports_unsupported_archive_format() {
let root = temp_path("extract-unsupported-key");
Expand Down
2 changes: 2 additions & 0 deletions src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ pub(super) struct ArchivePasswordOverlay {
pub(super) archive_path: PathBuf,
pub(super) input: String,
pub(super) cursor_col: usize,
pub(super) visible: bool,
pub(super) error: Option<String>,
}

Expand All @@ -178,6 +179,7 @@ impl fmt::Debug for ArchivePasswordOverlay {
.field("archive_path", &self.archive_path)
.field("input", &"<redacted>")
.field("cursor_col", &self.cursor_col)
.field("visible", &self.visible)
.field("error", &self.error)
.finish()
}
Expand Down
1 change: 1 addition & 0 deletions src/app/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub struct FrameState {
pub archive_create_panel: Option<Rect>,
pub archive_create_list_area: Option<Rect>,
pub archive_password_panel: Option<Rect>,
pub archive_password_visibility_btn: Option<Rect>,
pub create_panel: Option<Rect>,
pub rename_panel: Option<Rect>,
pub create_list_area: Option<Rect>,
Expand Down
1 change: 1 addition & 0 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub fn render(frame: &mut Frame<'_>, app: &App, state: &mut FrameState) {
state.archive_create_panel = None;
state.archive_create_list_area = None;
state.archive_password_panel = None;
state.archive_password_visibility_btn = None;
state.create_panel = None;
state.rename_panel = None;
state.create_list_area = None;
Expand Down
82 changes: 72 additions & 10 deletions src/ui/overlay_manager/archive_password.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::app::{App, FrameState};
use crate::ui::{helpers, theme::Palette};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Margin, Rect},
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Clear, Paragraph},
Expand All @@ -21,8 +21,17 @@ pub(super) fn render_archive_password_overlay(
helpers::clamp_label(archive_name, 30)
);
let popup_width = area.width.saturating_sub(8).clamp(40, 64);
let popup_height = 6u16;
let popup = helpers::centered_rect(area, popup_width, popup_height);
let popup_height = if area.height >= 4 {
6u16.min(area.height)
} else {
area.height
};
let popup = Rect {
x: area.x + area.width.saturating_sub(popup_width) / 2,
y: area.y + area.height.saturating_sub(popup_height) / 2,
width: popup_width.min(area.width),
height: popup_height,
};
state.archive_password_panel = Some(popup);

frame.render_widget(Clear, popup);
Expand All @@ -32,9 +41,13 @@ pub(super) fn render_archive_password_overlay(
);

let inner = helpers::inner_with_padding(popup);
let show_footer = inner.height >= 4;
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(1)])
.constraints([
Constraint::Length(3),
Constraint::Length(if show_footer { 1 } else { 0 }),
])
.split(inner);

frame.render_widget(
Expand All @@ -48,9 +61,25 @@ pub(super) fn render_archive_password_overlay(

let input = app.archive_password_input();
let cursor_col = app.archive_password_cursor_col();
let password_visible = app.archive_password_is_visible();
let toggle_icon = if password_visible { "" } else { "" };
let toggle_width = 3u16.min(input_area.width);
let input_columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(1), Constraint::Length(toggle_width)])
.split(input_area);
let text_area = input_columns[0];
let toggle_area = input_columns[1];
state.archive_password_visibility_btn = Some(toggle_area);

let masked_input = "*".repeat(input.chars().count());
let display_input = if password_visible {
input
} else {
&masked_input
};
let (visible_text, visible_cursor_col) =
helpers::input_window(&masked_input, cursor_col, input_area.width);
helpers::input_window(display_input, cursor_col, text_area.width);

let line = if input.is_empty() {
Line::from(Span::styled(
Expand All @@ -67,21 +96,54 @@ pub(super) fn render_archive_password_overlay(
};
frame.render_widget(
Paragraph::new(line).style(Style::default().bg(palette.path_bg).fg(palette.text)),
input_area,
text_area,
);

let toggle_style = Style::default()
.bg(palette.button_bg)
.fg(palette.text)
.add_modifier(Modifier::BOLD);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(toggle_icon, toggle_style)))
.alignment(Alignment::Center)
.style(toggle_style),
toggle_area,
);

let cursor_x =
(input_area.x + visible_cursor_col).min(input_area.x + input_area.width.saturating_sub(1));
frame.set_cursor_position((cursor_x, input_area.y));
(text_area.x + visible_cursor_col).min(text_area.x + text_area.width.saturating_sub(1));
frame.set_cursor_position((cursor_x, text_area.y));

if !show_footer {
return;
}

let hint_label = if password_visible {
"Alt+V hide"
} else {
"Alt+V show"
};
let hint_width = hint_label.chars().count() as u16;
let footer_columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(hint_width)])
.split(rows[1]);

frame.render_widget(
Paragraph::new(hint_label)
.alignment(Alignment::Right)
.style(Style::default().bg(palette.chrome_alt).fg(palette.muted)),
footer_columns[1],
);

if let Some(error) = app.archive_password_error() {
frame.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
helpers::clamp_label(error, rows[1].width.saturating_sub(2) as usize),
helpers::clamp_label(error, footer_columns[0].width.saturating_sub(1) as usize),
Style::default().fg(palette.accent),
)]))
.style(Style::default().bg(palette.chrome_alt).fg(palette.text)),
rows[1],
footer_columns[0],
);
}
}
Loading