Implement the password-store tree sidebar
This commit is contained in:
@@ -76,14 +76,21 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
}
|
||||
|
||||
if app.mode() == Mode::Help {
|
||||
let lines = crate::action::ACTIONS.iter().map(|spec| {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{:>7}", spec.binding.display),
|
||||
let available_rows = usize::from(area.height.saturating_sub(2)).max(1);
|
||||
let lines = (0..available_rows).map(|row| {
|
||||
let mut spans = Vec::new();
|
||||
for index in (row..crate::action::ACTIONS.len()).step_by(available_rows) {
|
||||
let spec = &crate::action::ACTIONS[index];
|
||||
spans.push(Span::styled(
|
||||
format!("{:>6}", spec.bindings[0].display),
|
||||
Style::default().fg(Color::Cyan),
|
||||
),
|
||||
Span::raw(format!(" {:<18} :{}", spec.label, spec.command)),
|
||||
])
|
||||
));
|
||||
spans.push(Span::raw(format!(
|
||||
" {:<16} :{:<16}",
|
||||
spec.label, spec.command
|
||||
)));
|
||||
}
|
||||
Line::from(spans)
|
||||
});
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines.collect::<Vec<_>>())
|
||||
@@ -112,9 +119,9 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
};
|
||||
let panes = Layout::new(direction, constraints).split(area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(sidebar_text(app))
|
||||
Paragraph::new(sidebar_lines(app))
|
||||
.block(pane_block("Passwords", app.focus() == PaneFocus::Sidebar))
|
||||
.wrap(Wrap { trim: true }),
|
||||
.wrap(Wrap { trim: false }),
|
||||
panes[0],
|
||||
);
|
||||
frame.render_widget(
|
||||
@@ -142,24 +149,62 @@ fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
|
||||
.border_style(style)
|
||||
}
|
||||
|
||||
fn sidebar_text(app: &App) -> String {
|
||||
if app.is_busy() {
|
||||
"Loading password-store tree…".to_owned()
|
||||
} else if let Some(startup) = app.startup() {
|
||||
format!("Vault\n{}", startup.vault)
|
||||
} else {
|
||||
"No password store is open.".to_owned()
|
||||
fn sidebar_lines(app: &App) -> Vec<Line<'static>> {
|
||||
let selected = app.sidebar().selected();
|
||||
let rows = app.sidebar().visible_window();
|
||||
if rows.is_empty() {
|
||||
if app.is_busy() {
|
||||
return vec![Line::from("Loading password-store tree…")];
|
||||
}
|
||||
return vec![Line::from("No password entries.")];
|
||||
}
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let marker = if row.id.is_directory() {
|
||||
if row.expanded { "▼" } else { "▶" }
|
||||
} else {
|
||||
"•"
|
||||
};
|
||||
let mut indicators = String::new();
|
||||
if row.indicators.is_locked() {
|
||||
indicators.push_str(" L");
|
||||
}
|
||||
if row.indicators.is_changed() {
|
||||
indicators.push_str(" *");
|
||||
}
|
||||
if row.indicators.has_conflict() {
|
||||
indicators.push_str(" !");
|
||||
}
|
||||
let style = if selected == Some(&row.id) {
|
||||
Style::default().bg(Color::Blue).fg(Color::White)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
Line::styled(
|
||||
format!(
|
||||
"{}{} {}{}",
|
||||
" ".repeat(row.depth),
|
||||
marker,
|
||||
row.name,
|
||||
indicators
|
||||
),
|
||||
style,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn main_text(app: &App) -> &'static str {
|
||||
fn main_text(app: &App) -> String {
|
||||
match app.mode() {
|
||||
Mode::Browser => "Select an entry from the sidebar.",
|
||||
Mode::Viewer => "Structured entry viewer",
|
||||
Mode::Editor => "Structured entry editor",
|
||||
Mode::Dialog => "Complete or cancel the active dialog.",
|
||||
Mode::Command => "Enter a command on the bottom line.",
|
||||
Mode::Help | Mode::Locked => "",
|
||||
Mode::Browser => "Select an entry from the sidebar.".to_owned(),
|
||||
Mode::Viewer => app.selected_entry().map_or_else(
|
||||
|| "Structured entry viewer".to_owned(),
|
||||
|path| format!("Opening {path}…"),
|
||||
),
|
||||
Mode::Editor => "Structured entry editor".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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +233,7 @@ fn status_line(app: &App) -> Paragraph<'_> {
|
||||
|
||||
fn context_line(app: &App) -> Paragraph<'static> {
|
||||
let text = available_actions(app.mode())
|
||||
.map(|spec| format!("{} {}", spec.binding.display, spec.label))
|
||||
.map(|spec| format!("{} {}", spec.bindings[0].display, spec.label))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
Paragraph::new(text)
|
||||
@@ -198,6 +243,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)),
|
||||
_ if app.sidebar().is_editing_filter() => {
|
||||
Paragraph::new(format!("/{}", app.sidebar().filter_query()))
|
||||
.style(Style::default().fg(Color::Yellow))
|
||||
}
|
||||
_ => Paragraph::new(""),
|
||||
}
|
||||
}
|
||||
@@ -208,6 +257,7 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::app::Transition;
|
||||
use crate::sidebar::TestTreeNode;
|
||||
|
||||
fn render(width: u16, height: u16, app: &App) -> String {
|
||||
let backend = TestBackend::new(width, height);
|
||||
@@ -268,4 +318,56 @@ mod tests {
|
||||
assert!(output.contains(spec.command));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hierarchy_selection_and_storage_indicators_have_stable_rendering() {
|
||||
let mut app = App::new();
|
||||
app.resize(100, 20);
|
||||
app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
|
||||
path: "personal".to_owned(),
|
||||
name: "personal".to_owned(),
|
||||
directory: true,
|
||||
indicators: ironstorage::read::TreeNodeIndicators::default(),
|
||||
children: vec![TestTreeNode {
|
||||
path: "personal/咖啡".to_owned(),
|
||||
name: "咖啡".to_owned(),
|
||||
directory: false,
|
||||
indicators: ironstorage::read::TreeNodeIndicators::new(true, true, true),
|
||||
children: vec![],
|
||||
}],
|
||||
}]);
|
||||
app.sidebar_mut().move_child();
|
||||
let output = render(100, 20, &app);
|
||||
assert!(output.contains("▼ personal"));
|
||||
assert!(output.contains('咖'));
|
||||
assert!(output.contains("L * !"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_focus_filter_prompt_and_resize_are_rendered() {
|
||||
let mut app = App::new();
|
||||
app.resize(60, 10);
|
||||
app.sidebar_mut().replace_test_tree(
|
||||
(0..20)
|
||||
.map(|index| TestTreeNode {
|
||||
path: format!("entry-{index:02}"),
|
||||
name: format!("entry-{index:02}"),
|
||||
directory: false,
|
||||
indicators: ironstorage::read::TreeNodeIndicators::default(),
|
||||
children: vec![],
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
app.sidebar_mut().move_last();
|
||||
app.sidebar_mut().begin_filter();
|
||||
app.sidebar_mut().push_filter_character('咖');
|
||||
let narrow = render(60, 10, &app);
|
||||
assert!(narrow.contains("entry-19"));
|
||||
assert!(narrow.contains("/咖"));
|
||||
assert!(!narrow.contains("entry-00"));
|
||||
|
||||
app.dispatch(crate::action::Action::FocusNext);
|
||||
let wide = render(140, 20, &app);
|
||||
assert!(wide.contains("Browser"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user