Skip to content

Commit dea7e2e

Browse files
committed
Improve filename validation in Render as modal
1 parent 66d976a commit dea7e2e

5 files changed

Lines changed: 93 additions & 39 deletions

File tree

src/app.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use std::{mem, thread};
88
use arboard::Clipboard;
99
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1010
use ratatui::{DefaultTerminal, widgets::ListState};
11+
use tracing::debug;
1112

1213
use crate::info::Info;
1314
use crate::model::{AppEvent, Pane};
@@ -37,6 +38,7 @@ pub(crate) struct App<'a> {
3738
pub params: Vec<Parameter>,
3839
pub params_list_state: ListState,
3940
// Input
41+
pub original_filename: Box<str>,
4042
pub source: Source,
4143
pub info_state: InfoPaneState<'a>,
4244
// Output
@@ -56,6 +58,11 @@ impl App<'_> {
5658
let theme = Theme::new();
5759
let info_state = InfoPaneState::new(info.format(&theme));
5860
let (filename, fileext) = source.input_name_and_ext(info);
61+
let original_filename = if source.is_url() {
62+
"".into()
63+
} else {
64+
filename.clone().into()
65+
};
5966
Self {
6067
running: false,
6168
event_sender: tx,
@@ -69,6 +76,7 @@ impl App<'_> {
6976
params: create_params(info, preset, fileext.as_str()),
7077
params_list_state: list_state,
7178
// Info
79+
original_filename,
7280
source,
7381
info_state,
7482
// Output
@@ -168,6 +176,7 @@ impl App<'_> {
168176
let output_ext = get_output_format(&self.params)
169177
.map_or(&self.output_fileext, |option| &option.value);
170178
self.modal = Some(Box::new(SaveAsFileModal::new(
179+
&self.original_filename,
171180
&self.output_folder,
172181
&self.output_filename,
173182
output_ext,
@@ -307,6 +316,7 @@ impl App<'_> {
307316
self.active_out_pane = Pane::Output;
308317

309318
let args = self.build_ffmpeg_command(true);
319+
debug!(?args, "Starting FFmpeg");
310320
self.out_state.set_output("Starting FFmpeg...\n");
311321

312322
let tx = self.event_sender.clone();
@@ -379,7 +389,7 @@ impl App<'_> {
379389
let mut path = PathBuf::new()
380390
.join(&*self.output_folder)
381391
.join(&*self.output_filename);
382-
path.set_extension(&*command_builder.ext);
392+
path.add_extension(&*command_builder.ext);
383393
let output_file = path.display().to_string();
384394

385395
let mut args: Vec<String> = Vec::new();

src/source.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ impl Source {
2626
Self { input, source_type }
2727
}
2828

29+
pub(crate) fn is_url(&self) -> bool {
30+
self.source_type == SourceType::Url
31+
}
32+
2933
pub(crate) fn validate(&self) -> Result<(), String> {
3034
if self.source_type == SourceType::File && std::fs::metadata(&self.input).is_err() {
3135
return Err(format!("Input file '{}' does not exist", &self.input));

src/ui/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ fn is_portrait(area: Rect) -> bool {
2828

2929
/// Get the value and cursor position of an input widget
3030
fn input_value_and_pos(input: &Input, width: u16) -> (String, u16) {
31-
let scroll = input.visual_scroll(width as usize).max(3) - 3;
31+
let scroll = input.visual_scroll(width as usize);
3232
let value = input
3333
.value()
3434
.chars()

src/ui/modal_custom_select.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ impl UiModal for CustomSelectModal {
3838
.vertical_margin(1)
3939
.areas(modal_area);
4040

41-
let (display_value, x) = input_value_and_pos(&self.input, input_area.width);
41+
let (display_value, x) = input_value_and_pos(&self.input, input_area.width - 1); // subtract margin.x / 2
4242

4343
frame.render_widget(BgClear::new(theme.background_color()), modal_area);
4444
Block::bordered()

src/ui/modal_save_as_file.rs

Lines changed: 76 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,29 @@ use ratatui::{
1010
text::Line,
1111
widgets::{Block, Paragraph, Widget as _},
1212
};
13+
use regex::Regex;
14+
use tracing::debug;
1315
use tui_input::Input;
1416
use tui_input::backend::crossterm::EventHandler as _;
1517

1618
use crate::ui::widget::BgClear;
1719
use crate::ui::{KeyboardHandler, ModalResult, Theme, UiModal, input_value_and_pos, is_portrait};
1820

1921
#[derive(Debug, PartialEq)]
20-
enum Overwrite {
22+
enum ValidationResult {
2123
Reset,
22-
Prompted,
23-
Confirmed,
24+
Ok,
25+
Invalid(String),
26+
Exists,
2427
}
2528

2629
#[derive(Debug)]
2730
pub(crate) struct SaveAsFileModal {
2831
filename: Input,
32+
original_filename: Box<str>,
2933
folder: Box<str>,
3034
ext: Box<str>,
31-
overwrite: Overwrite,
35+
validation: ValidationResult,
3236
}
3337

3438
impl UiModal for SaveAsFileModal {
@@ -45,7 +49,7 @@ impl UiModal for SaveAsFileModal {
4549
.vertical_margin(1)
4650
.areas(modal_area);
4751

48-
let (display_value, x) = input_value_and_pos(&self.filename, input_area.width);
52+
let (display_value, x) = input_value_and_pos(&self.filename, input_area.width - 2);
4953

5054
frame.render_widget(BgClear::new(theme.background_color()), modal_area);
5155
Block::bordered()
@@ -71,60 +75,96 @@ impl KeyboardHandler for SaveAsFileModal {
7175
if key.code == KeyCode::Esc {
7276
ModalResult::Close
7377
} else if key.code == KeyCode::Enter {
74-
if self.overwrite == Overwrite::Prompted {
75-
self.overwrite = Overwrite::Confirmed;
76-
}
7778
let filename = self.filename.value().trim();
78-
let valid = !filename.is_empty() && !self.is_file_exists(filename);
79-
if valid || self.overwrite == Overwrite::Confirmed {
80-
ModalResult::Filename(filename.to_owned())
81-
} else {
82-
self.overwrite = Overwrite::Prompted;
83-
ModalResult::None
79+
// Enter pressed after the overwrite prompt
80+
if self.validation == ValidationResult::Exists {
81+
debug!(filename, "Save as. Overwrite");
82+
return ModalResult::Filename(filename.to_owned());
83+
}
84+
self.validation = self.validate(filename);
85+
match self.validation {
86+
ValidationResult::Ok => {
87+
debug!(filename, "Save as");
88+
ModalResult::Filename(filename.to_owned())
89+
}
90+
_ => ModalResult::None,
8491
}
8592
} else {
86-
self.overwrite = Overwrite::Reset;
93+
self.validation = ValidationResult::Reset;
8794
self.filename.handle_event(&Event::Key(key));
8895
ModalResult::None
8996
}
9097
}
9198
}
9299

93100
impl SaveAsFileModal {
94-
pub(crate) fn new(folder: &str, filename: &str, ext: &str) -> Self {
101+
pub(crate) fn new(original_filename: &str, folder: &str, filename: &str, ext: &str) -> Self {
95102
Self {
96103
filename: Input::new(filename.to_owned()),
104+
original_filename: original_filename.into(),
97105
folder: folder.into(),
98106
ext: ext.into(),
99-
overwrite: Overwrite::Reset,
107+
validation: ValidationResult::Reset,
100108
}
101109
}
102110

103111
fn render_input_hints(&self, area: Rect, frame: &mut Frame, theme: &Theme) {
104-
let line = if self.overwrite == Overwrite::Prompted {
105-
let error = theme.error_style().bold();
106-
Line::from(vec![
107-
Span::styled("File already exists. Press ", error),
108-
Span::styled("Enter", theme.key_style()),
109-
Span::styled(" again to overwrite", error),
110-
])
111-
.centered()
112-
} else {
113-
let key_style = theme.key_style();
114-
let text_style = theme.text_color();
115-
Line::from(vec![
116-
Span::styled("Enter", key_style),
117-
Span::styled(": confirm ", text_style),
118-
Span::styled("Esc", key_style),
119-
Span::styled(": close", text_style),
120-
])
112+
let line = match &self.validation {
113+
ValidationResult::Exists => {
114+
let error_style = theme.error_style().bold();
115+
Line::from(vec![
116+
Span::styled("File already exists. Press ", error_style),
117+
Span::styled("Enter", theme.key_style()),
118+
Span::styled(" again to overwrite", error_style),
119+
])
120+
.centered()
121+
}
122+
ValidationResult::Invalid(reason) => {
123+
let error_style = theme.error_style().bold();
124+
Line::from(vec![Span::styled(reason, error_style)]).centered()
125+
}
126+
_ => {
127+
let key_style = theme.key_style();
128+
let text_style = theme.text_color();
129+
Line::from(vec![
130+
Span::styled("Enter", key_style),
131+
Span::styled(": confirm ", text_style),
132+
Span::styled("Esc", key_style),
133+
Span::styled(": close", text_style),
134+
])
135+
}
121136
};
122137
frame.render_widget(Paragraph::new(line), area);
123138
}
124139

140+
fn validate(&self, filename: &str) -> ValidationResult {
141+
if filename.is_empty() {
142+
ValidationResult::Invalid("Filename is empty".into())
143+
} else if filename == self.original_filename.as_ref() {
144+
ValidationResult::Invalid("Filename is the same as original".into())
145+
} else if Self::contains_invalid_chars(filename) {
146+
ValidationResult::Invalid("Filename contains invalid characters".into())
147+
} else if filename.len() > 200 {
148+
ValidationResult::Invalid("Filename is too long".into())
149+
} else if self.is_file_exists(filename) {
150+
ValidationResult::Exists
151+
} else {
152+
ValidationResult::Ok
153+
}
154+
}
155+
156+
fn contains_invalid_chars(filename: &str) -> bool {
157+
filename.starts_with('-')
158+
|| filename.starts_with('~')
159+
|| std::str::from_utf8(filename.as_bytes()).is_err()
160+
|| Regex::new(r"[/\\|<>$:\x00-\x1F\x7F\x80-\x9F]+")
161+
.unwrap()
162+
.is_match(filename)
163+
}
164+
125165
fn is_file_exists(&self, filename: &str) -> bool {
126-
let mut path = PathBuf::new().join(&*self.folder).join(filename);
127-
path.set_extension(&*self.ext);
166+
let mut path = PathBuf::new().join(self.folder.as_ref()).join(filename);
167+
path.add_extension(self.ext.as_ref());
128168
path.exists()
129169
}
130170
}

0 commit comments

Comments
 (0)