@@ -11,6 +11,7 @@ use ratatui::{
|
||||
use crate::{
|
||||
action::available_actions,
|
||||
app::{App, Mode, PaneFocus},
|
||||
editor::EntryEditor,
|
||||
viewer::EntryViewer,
|
||||
};
|
||||
|
||||
@@ -125,16 +126,24 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
.wrap(Wrap { trim: false }),
|
||||
panes[0],
|
||||
);
|
||||
let main = if app.mode() == Mode::Viewer {
|
||||
app.viewer().map_or_else(
|
||||
let main = match app.mode() {
|
||||
Mode::Viewer => app.viewer().map_or_else(
|
||||
|| Paragraph::new(main_text(app)),
|
||||
|viewer| {
|
||||
Paragraph::new(viewer_lines(viewer))
|
||||
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Paragraph::new(main_text(app))
|
||||
),
|
||||
Mode::Editor => app.editor().map_or_else(
|
||||
|| Paragraph::new(main_text(app)),
|
||||
|editor| {
|
||||
Paragraph::new(editor_lines(editor)).scroll((
|
||||
u16::try_from(editor.focused_index().unwrap_or_default()).unwrap_or(u16::MAX),
|
||||
0,
|
||||
))
|
||||
},
|
||||
),
|
||||
_ => Paragraph::new(main_text(app)),
|
||||
};
|
||||
frame.render_widget(
|
||||
main.block(pane_block(
|
||||
@@ -212,7 +221,10 @@ fn main_text(app: &App) -> String {
|
||||
|| "Structured entry viewer".to_owned(),
|
||||
|path| format!("Opening {path}…"),
|
||||
),
|
||||
Mode::Editor => "Structured entry editor".to_owned(),
|
||||
Mode::Editor => "Saving structured entry…".to_owned(),
|
||||
Mode::Dialog if app.discard_confirmation() => {
|
||||
"Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned()
|
||||
}
|
||||
Mode::Dialog => "Complete or cancel the active dialog.".to_owned(),
|
||||
Mode::Command => "Enter a command on the bottom line.".to_owned(),
|
||||
Mode::Help | Mode::Locked => String::new(),
|
||||
@@ -289,6 +301,78 @@ fn viewer_lines(viewer: &EntryViewer) -> Vec<Line<'_>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
|
||||
let focused = editor.focused_index();
|
||||
if editor.document().fields().is_empty() {
|
||||
return vec![Line::from(
|
||||
"This entry is empty. Press a to add its first field.",
|
||||
)];
|
||||
}
|
||||
|
||||
editor
|
||||
.document()
|
||||
.fields()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, field)| {
|
||||
let selected = focused == Some(index);
|
||||
let contents = editor
|
||||
.focused_contents(field.id())
|
||||
.unwrap_or_else(|| field.contents().expose());
|
||||
let label = field.metadata().name().map_or_else(
|
||||
|| format!("{:?}", field.metadata().kind()),
|
||||
|name| format!("{:?} ({name})", field.metadata().kind()),
|
||||
);
|
||||
let masked = field.metadata().sensitivity()
|
||||
== ironstorage::document::EntrySensitivity::Sensitive
|
||||
&& !editor.is_revealed(field.id());
|
||||
let mut spans = vec![Span::styled(
|
||||
format!("#{:02} {label}: ", index + 1),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
if masked {
|
||||
spans.push(Span::styled(
|
||||
"••••••••",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
if selected && editor.is_input_active() {
|
||||
spans.push(Span::styled(
|
||||
" [hidden input]",
|
||||
Style::default().fg(Color::Yellow),
|
||||
));
|
||||
}
|
||||
} else if let Ok(value) = std::str::from_utf8(contents) {
|
||||
if selected && editor.is_input_active() && value.is_char_boundary(editor.cursor()) {
|
||||
let (before, after) = value.split_at(editor.cursor());
|
||||
spans.push(Span::raw(before));
|
||||
spans.push(Span::styled("▏", Style::default().fg(Color::Yellow)));
|
||||
spans.push(Span::raw(after));
|
||||
} else if value.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
"(empty)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
} else {
|
||||
spans.push(Span::raw(value));
|
||||
}
|
||||
} else {
|
||||
spans.push(Span::styled(
|
||||
"[non-UTF-8 field; editing will preserve bytes]",
|
||||
Style::default().fg(Color::Yellow),
|
||||
));
|
||||
}
|
||||
let line = Line::from(spans);
|
||||
if selected {
|
||||
line.style(Style::default().bg(Color::Blue).fg(Color::White))
|
||||
} else {
|
||||
line
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mode_title(mode: Mode) -> &'static str {
|
||||
match mode {
|
||||
Mode::Browser => "Browser",
|
||||
@@ -330,6 +414,10 @@ fn prompt_line(app: &App) -> Paragraph<'static> {
|
||||
match app.mode() {
|
||||
Mode::Command => Paragraph::new(":").style(Style::default().fg(Color::Yellow)),
|
||||
Mode::Dialog => Paragraph::new("dialog> ").style(Style::default().fg(Color::Yellow)),
|
||||
Mode::Editor if app.editor().is_some_and(EntryEditor::is_input_active) => {
|
||||
Paragraph::new("-- INSERT -- Esc stops input; Tab changes field; C-s saves")
|
||||
.style(Style::default().fg(Color::Yellow))
|
||||
}
|
||||
_ if app.sidebar().is_editing_filter() => {
|
||||
Paragraph::new(format!("/{}", app.sidebar().filter_query()))
|
||||
.style(Style::default().fg(Color::Yellow))
|
||||
@@ -400,7 +488,7 @@ mod tests {
|
||||
fn help_is_generated_from_the_action_registry() {
|
||||
let mut app = App::new();
|
||||
assert!(app.transition(Transition::OpenHelp));
|
||||
let output = render(100, 20, &app);
|
||||
let output = render(140, 35, &app);
|
||||
for spec in crate::action::ACTIONS {
|
||||
assert!(output.contains(spec.label));
|
||||
assert!(output.contains(spec.command));
|
||||
@@ -539,4 +627,53 @@ mod tests {
|
||||
assert_eq!(app.sidebar().selected(), selected.as_ref());
|
||||
assert!(app.viewer().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_keeps_sensitive_input_masked_until_explicit_reveal() {
|
||||
let mut app = App::new();
|
||||
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||||
app.dispatch(crate::action::Action::EditEntry);
|
||||
app.dispatch(crate::action::Action::BeginInput);
|
||||
app.handle_editor_input(crossterm::event::KeyCode::Char('x'));
|
||||
|
||||
for width in [60, 100, 140] {
|
||||
let hidden = render(width, 20, &app);
|
||||
assert!(hidden.contains("hidden input"));
|
||||
assert!(!hidden.contains("correct horse fixturex"));
|
||||
}
|
||||
|
||||
app.handle_editor_input(crossterm::event::KeyCode::Esc);
|
||||
app.dispatch(crate::action::Action::Reveal);
|
||||
let revealed = render(100, 20, &app);
|
||||
assert!(revealed.contains("correct horse fixturex"));
|
||||
assert!(!format!("{app:?}").contains("correct horse fixturex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_editor_requires_explicit_discard_and_relock_discards_from_dialog() {
|
||||
let mut app = App::new();
|
||||
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||||
app.dispatch(crate::action::Action::EditEntry);
|
||||
app.dispatch(crate::action::Action::AddField);
|
||||
app.dispatch(crate::action::Action::Cancel);
|
||||
assert_eq!(app.mode(), Mode::Dialog);
|
||||
assert!(app.discard_confirmation());
|
||||
assert!(render(100, 20, &app).contains("Discard all unsaved edits"));
|
||||
|
||||
app.dispatch(crate::action::Action::KeepEditing);
|
||||
assert_eq!(app.mode(), Mode::Editor);
|
||||
app.dispatch(crate::action::Action::Cancel);
|
||||
app.dispatch(crate::action::Action::ConfirmDiscard);
|
||||
assert_eq!(app.mode(), Mode::Browser);
|
||||
assert!(app.editor().is_none());
|
||||
|
||||
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||||
app.dispatch(crate::action::Action::EditEntry);
|
||||
app.dispatch(crate::action::Action::AddField);
|
||||
app.dispatch(crate::action::Action::Cancel);
|
||||
app.forced_relock("test expiry");
|
||||
assert_eq!(app.mode(), Mode::Locked);
|
||||
assert!(app.editor().is_none());
|
||||
assert!(app.status().contains("unsaved edits were discarded"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user