From 581518ef2ff5cb9efe629e07f7aed27e32296514 Mon Sep 17 00:00:00 2001 From: MiguelRegueiro Date: Fri, 3 Jul 2026 18:16:56 +0200 Subject: [PATCH] feat(archive): add password visibility toggle --- CHANGELOG.md | 1 + src/app/actions/archive.rs | 29 ++++++++ src/app/input/tests/archive.rs | 32 +++++++++ src/app/state.rs | 2 + src/app/types.rs | 1 + src/ui/mod.rs | 1 + src/ui/overlay_manager/archive_password.rs | 82 +++++++++++++++++++--- 7 files changed, 138 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15daa54..2b02b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/app/actions/archive.rs b/src/app/actions/archive.rs index 2c4c241..2c2ebf6 100644 --- a/src/app/actions/archive.rs +++ b/src/app/actions/archive.rs @@ -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; @@ -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 diff --git a/src/app/input/tests/archive.rs b/src/app/input/tests/archive.rs index 0976b90..ae2d396 100644 --- a/src/app/input/tests/archive.rs +++ b/src/app/input/tests/archive.rs @@ -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"); diff --git a/src/app/state.rs b/src/app/state.rs index 75bd57f..462fdd1 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -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, } @@ -178,6 +179,7 @@ impl fmt::Debug for ArchivePasswordOverlay { .field("archive_path", &self.archive_path) .field("input", &"") .field("cursor_col", &self.cursor_col) + .field("visible", &self.visible) .field("error", &self.error) .finish() } diff --git a/src/app/types.rs b/src/app/types.rs index c931439..6c5e4d5 100644 --- a/src/app/types.rs +++ b/src/app/types.rs @@ -47,6 +47,7 @@ pub struct FrameState { pub archive_create_panel: Option, pub archive_create_list_area: Option, pub archive_password_panel: Option, + pub archive_password_visibility_btn: Option, pub create_panel: Option, pub rename_panel: Option, pub create_list_area: Option, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 200a9b2..ab1b854 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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; diff --git a/src/ui/overlay_manager/archive_password.rs b/src/ui/overlay_manager/archive_password.rs index 5052cb0..52a77f9 100644 --- a/src/ui/overlay_manager/archive_password.rs +++ b/src/ui/overlay_manager/archive_password.rs @@ -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}, @@ -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); @@ -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( @@ -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( @@ -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], ); } }