Add desktop navigation tree

This commit is contained in:
2026-08-10 16:03:30 +02:00
parent 49451bc988
commit 08f74bfa72
3 changed files with 825 additions and 45 deletions

View File

@@ -2,6 +2,7 @@
#![deny(clippy::disallowed_types)]
mod editor;
mod navigation;
use std::{
sync::{
@@ -14,8 +15,8 @@ use std::{
use editor::EntryEditor;
use iced::{
Element, Event, Length, Subscription, Task, event, keyboard, mouse, time, touch,
widget::{button, column, container, row, scrollable, text, text_input},
Element, Event, Length, Size, Subscription, Task, event, keyboard, mouse, time, touch,
widget::{button, column, container, pane_grid, row, scrollable, text, text_input},
window,
};
use ironstorage::{
@@ -29,18 +30,30 @@ use ironstorage::{
generate::GeneratorConfig,
git::{AutomaticEntryCommitter, GitIdentity},
presentation::{ClipboardWait, NativeClipboardManager},
read::{TreeModel, TreeNodeId},
repository::{Repository, SecretBytes},
secret_store::{SecretProtectionPolicy, SecretStoreBackend},
write::{WriteError, WriteOutcome},
};
use navigation::{NavigationIntent, NavigationKey, NavigationTree};
use zeroize::Zeroizing;
type OpenCompletion = Arc<Mutex<Option<Result<EntryDocument, String>>>>;
type SaveCompletion = Arc<Mutex<Option<(EntryEditor, Result<WriteOutcome, SaveFailure>)>>>;
type TreeCompletion = Arc<Mutex<Option<Result<TreeModel, String>>>>;
#[derive(Clone)]
enum Message {
StartupLoaded(Box<Result<(Config, NativeAuthenticationSession, KeyInfo), String>>),
TreeLoaded {
generation: u64,
completion: TreeCompletion,
},
RefreshTree,
SidebarActivate(TreeNodeId),
SidebarNavigate(NavigationKey),
TogglePaneFocus,
PaneResized(pane_grid::ResizeEvent),
EntryPathChanged(String),
OpenEntry,
OpenFinished {
@@ -131,6 +144,11 @@ struct App {
sensitive: SensitiveUiState,
authentication_generation: u64,
operation_generation: u64,
tree_generation: u64,
panes: pane_grid::State<PaneKind>,
pane_focus: PaneFocus,
navigation: NavigationTree,
tree_state: TreeState,
after_authentication: Option<PendingAction>,
entry_path: String,
editor: Option<EntryEditor>,
@@ -142,6 +160,25 @@ struct App {
status: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PaneKind {
Sidebar,
Content,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PaneFocus {
Sidebar,
Content,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum TreeState {
Loading,
Ready,
Error(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum PendingAction {
OpenEntry(String),
@@ -180,6 +217,11 @@ fn main() -> iced::Result {
.title(ironstorage::PRODUCT_NAME)
.subscription(App::subscription)
.exit_on_close_request(false)
.window(window::Settings {
size: Size::new(1_080.0, 720.0),
min_size: Some(Size::new(720.0, 480.0)),
..window::Settings::default()
})
.run()
}
@@ -195,6 +237,16 @@ impl App {
sensitive: SensitiveUiState::default(),
authentication_generation: 0,
operation_generation: 0,
tree_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical,
ratio: 0.28,
a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)),
b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)),
}),
pane_focus: PaneFocus::Sidebar,
navigation: NavigationTree::default(),
tree_state: TreeState::Loading,
after_authentication: None,
entry_path: String::new(),
editor: None,
@@ -221,14 +273,71 @@ impl App {
self.authentication = AuthenticationView::Locked;
self.status = "Entry names remain available while protected content is locked."
.to_owned();
return self.begin_tree_refresh();
}
Err(error) => {
self.tree_state = TreeState::Error(error.clone());
self.authentication = AuthenticationView::Unavailable(error.clone());
self.status = error;
}
},
Message::EntryPathChanged(path) => self.entry_path = path,
Message::TreeLoaded {
generation,
completion,
} => {
let Some(result) = take_completion(&completion) else {
return Task::none();
};
if generation != self.tree_generation {
return Task::none();
}
match result {
Ok(model) => {
self.navigation.replace(&model);
self.tree_state = TreeState::Ready;
self.status = if self.navigation.is_empty() {
"The password store is empty.".to_owned()
} else {
"Password-store tree refreshed.".to_owned()
};
}
Err(error) => {
self.tree_state = TreeState::Error(error.clone());
self.status = format!("Tree refresh failed: {error}");
}
}
}
Message::RefreshTree => return self.begin_tree_refresh(),
Message::SidebarActivate(id) => {
self.touch_user_activity();
self.pane_focus = PaneFocus::Sidebar;
let intent = self.navigation.activate(id);
return self.handle_navigation(intent);
}
Message::SidebarNavigate(key) => {
self.touch_user_activity();
if self.pane_focus == PaneFocus::Sidebar {
let intent = self.navigation.navigate(key);
return self.handle_navigation(intent);
}
}
Message::TogglePaneFocus => {
self.touch_user_activity();
self.pane_focus = match self.pane_focus {
PaneFocus::Sidebar => PaneFocus::Content,
PaneFocus::Content => PaneFocus::Sidebar,
};
}
Message::PaneResized(event) => {
self.panes
.resize(event.split, event.ratio.clamp(0.18, 0.55));
}
Message::EntryPathChanged(path) => {
self.pane_focus = PaneFocus::Content;
self.entry_path = path;
}
Message::OpenEntry => {
self.pane_focus = PaneFocus::Content;
let entry = self.entry_path.trim().to_owned();
if entry.is_empty() {
self.status = "Enter an entry path first.".to_owned();
@@ -250,6 +359,8 @@ impl App {
match result {
Ok(document) => {
self.entry_path = entry.clone();
let _selected = self.navigation.select_entry_path(&entry);
self.pane_focus = PaneFocus::Content;
self.editor = Some(EntryEditor::new(document));
self.conflict = false;
self.status = format!("Editing {entry}");
@@ -285,6 +396,7 @@ impl App {
}
}
Message::FieldChanged(id, value) => {
self.pane_focus = PaneFocus::Content;
self.edit(|editor| editor.update_raw(id, value.as_bytes()));
}
Message::AddAfter(id) => self.edit(|editor| editor.add_after(id)),
@@ -422,6 +534,37 @@ impl App {
Task::none()
}
fn begin_tree_refresh(&mut self) -> Task<Message> {
let Some(config) = self.config.clone() else {
return Task::none();
};
self.tree_generation = self.tree_generation.wrapping_add(1);
let generation = self.tree_generation;
self.tree_state = TreeState::Loading;
Task::perform(
async move { Arc::new(Mutex::new(Some(load_tree(&config)))) },
move |completion| Message::TreeLoaded {
generation,
completion,
},
)
}
fn handle_navigation(&mut self, intent: NavigationIntent) -> Task<Message> {
match intent {
NavigationIntent::OpenEntry(entry) => {
self.request_action(PendingAction::OpenEntry(entry))
}
NavigationIntent::None => iced::widget::operation::snap_to(
sidebar_scroll_id(),
scrollable::RelativeOffset {
x: 0.0,
y: self.navigation.selected_ratio(),
},
),
}
}
fn request_action(&mut self, action: PendingAction) -> Task<Message> {
if self.saving {
self.after_save = Some(action);
@@ -429,6 +572,9 @@ impl App {
return Task::none();
}
if dirty_decision(self.editor.as_ref()) == DirtyDecision::Confirm {
if let Some(entry) = self.editor.as_ref().map(EntryEditor::entry) {
let _restored = self.navigation.select_entry_path(&entry);
}
self.confirmation = Some(action);
Task::none()
} else {
@@ -597,39 +743,32 @@ impl App {
}
AuthenticationView::Unavailable(error) => format!("Unavailable · {error}"),
};
let lock = button("Lock").on_press(Message::Lock);
let open = row![
text_input("Entry path", &self.entry_path)
.on_input(Message::EntryPathChanged)
.on_submit(Message::OpenEntry),
button("Edit").on_press(Message::OpenEntry),
button("Reload").on_press(Message::RequestReload),
lock,
]
.spacing(8);
let body: Element<'_, Message> = if self.saving {
container(text("Saving without discarding the draft…"))
.center(Length::Fill)
.into()
} else if let Some(editor) = &self.editor {
editor_view(editor, self.conflict)
} else {
container(text(
"Enter an encrypted entry path. Browsing its name does not authenticate; Edit does.",
))
.center(Length::Fill)
.into()
};
let panes = pane_grid(&self.panes, |_pane, kind, _maximized| {
pane_grid::Content::new(match kind {
PaneKind::Sidebar => sidebar_view(
&self.navigation,
&self.tree_state,
self.pane_focus == PaneFocus::Sidebar,
),
PaneKind::Content => content_view(self),
})
})
.spacing(1)
.min_size(220)
.on_resize(8, Message::PaneResized);
container(
column![
row![text(authentication), text(&self.status).size(14)].spacing(16),
open,
body,
row![
text(authentication),
text(&self.status).size(14),
text("Tab changes pane focus").size(12),
]
.spacing(16)
.padding(10),
panes,
]
.spacing(12)
.padding(16),
.height(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
@@ -637,6 +776,127 @@ impl App {
}
}
fn sidebar_scroll_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-navigation-tree")
}
fn sidebar_view<'a>(
navigation: &'a NavigationTree,
state: &'a TreeState,
focused: bool,
) -> Element<'a, Message> {
let mut rows = column![
row![
text(if focused {
"Password Store · focused"
} else {
"Password Store"
})
.size(20),
button("Refresh").on_press(Message::RefreshTree),
]
.spacing(8)
]
.spacing(4)
.padding(10);
match state {
TreeState::Loading => {
rows = rows.push(text("Loading password-store tree…").size(13));
}
TreeState::Error(error) => {
rows = rows.push(text(format!("Tree unavailable: {error}")).size(13));
}
TreeState::Ready if navigation.is_empty() => {
rows = rows.push(text("This password store is empty.").size(13));
}
TreeState::Ready => {}
}
let selected = navigation.selected();
for node in navigation.rows() {
let marker = if node.id.is_directory() {
if node.expanded { "" } else { "" }
} else {
""
};
let mut flags = Vec::new();
if node.indicators.has_conflict() {
flags.push("conflict");
}
if node.indicators.is_changed() {
flags.push("changed");
}
if node.indicators.is_locked() {
flags.push("locked");
}
let suffix = if flags.is_empty() {
String::new()
} else {
format!(" · {}", flags.join(", "))
};
let selected = selected == Some(&node.id);
let item = button(
row![
container(text("")).width(Length::Fixed((node.depth * 16) as f32)),
text(marker),
text(format!("{}{}", node.name, suffix)),
]
.spacing(5),
)
.width(Length::Fill)
.on_press(Message::SidebarActivate(node.id))
.style(if selected {
button::primary
} else {
button::text
});
rows = rows.push(item);
}
scrollable(rows)
.id(sidebar_scroll_id())
.height(Length::Fill)
.into()
}
fn content_view(app: &App) -> Element<'_, Message> {
let open = row![
text(if app.pane_focus == PaneFocus::Content {
"Content · focused"
} else {
"Content"
})
.size(20),
text_input("Entry path", &app.entry_path)
.on_input(Message::EntryPathChanged)
.on_submit(Message::OpenEntry),
button("Edit").on_press(Message::OpenEntry),
button("Reload").on_press(Message::RequestReload),
button("Lock").on_press(Message::Lock),
]
.spacing(8);
let body: Element<'_, Message> = if app.saving {
container(text("Saving without discarding the draft…"))
.center(Length::Fill)
.into()
} else if let Some(editor) = &app.editor {
editor_view(editor, app.conflict)
} else {
container(text(
"Select an entry in the navigation tree. Entry names remain visible while locked; protected content authenticates only when opened.",
))
.center(Length::Fill)
.into()
};
container(column![open, body].spacing(12).padding(12))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
impl SaveFailure {
fn storage(message: String) -> Self {
Self {
@@ -804,6 +1064,14 @@ fn load_document(
.map_err(|error| error.to_string())
}
fn load_tree(config: &Config) -> Result<TreeModel, String> {
let repository = Repository::open(config.vault()).map_err(|error| error.to_string())?;
let keys = KeyStore::load(config.key_material()).map_err(|error| error.to_string())?;
ironstorage::read::VaultReader::new(&repository, &keys)
.list(&ironstorage::repository::DirectoryPath::root())
.map_err(|error| error.to_string())
}
fn save_document(config: &Config, editor: &EntryEditor) -> Result<WriteOutcome, SaveFailure> {
let repository = Repository::open(config.vault())
.map_err(|error| SaveFailure::storage(error.to_string()))?;
@@ -871,11 +1139,24 @@ fn poll_lease<B: SecretStoreBackend, C: AuthenticationClock>(
}
fn event_message(event: &Event) -> Option<Message> {
if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event
&& modifiers.command()
&& matches!(key.as_ref(), keyboard::Key::Character("s" | "S"))
{
return Some(Message::Save);
if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
if modifiers.command() && matches!(key.as_ref(), keyboard::Key::Character("s" | "S")) {
return Some(Message::Save);
}
let navigation = match key.as_ref() {
keyboard::Key::Named(keyboard::key::Named::Tab) => {
return Some(Message::TogglePaneFocus);
}
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => NavigationKey::Previous,
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => NavigationKey::Next,
keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => NavigationKey::Collapse,
keyboard::Key::Named(keyboard::key::Named::ArrowRight) => NavigationKey::Expand,
keyboard::Key::Named(keyboard::key::Named::Enter) => NavigationKey::Activate,
keyboard::Key::Named(keyboard::key::Named::Home) => NavigationKey::First,
keyboard::Key::Named(keyboard::key::Named::End) => NavigationKey::Last,
_ => return is_deliberate_activity(event).then_some(Message::UserActivity),
};
return Some(Message::SidebarNavigate(navigation));
}
is_deliberate_activity(event).then_some(Message::UserActivity)
}
@@ -1085,6 +1366,16 @@ mod tests {
sensitive: SensitiveUiState::default(),
authentication_generation: 0,
operation_generation: 0,
tree_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical,
ratio: 0.28,
a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)),
b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)),
}),
pane_focus: PaneFocus::Sidebar,
navigation: NavigationTree::default(),
tree_state: TreeState::Loading,
after_authentication: None,
entry_path: String::new(),
editor,
@@ -1190,7 +1481,27 @@ mod tests {
let draft = editor.document().serialize().expose().to_vec();
let mut app = test_app(Some(editor));
let _task = app.request_action(PendingAction::OpenEntry("other".to_owned()));
let draft_id = TreeNodeId::Entry(EntryPath::parse("draft").expect("draft path"));
let other_id = TreeNodeId::Entry(EntryPath::parse("other").expect("other path"));
app.navigation.replace_test_nodes(vec![
navigation::TestNode {
id: draft_id,
name: "draft".to_owned(),
children: Vec::new(),
},
navigation::TestNode {
id: other_id.clone(),
name: "other".to_owned(),
children: Vec::new(),
},
]);
assert!(app.navigation.select_entry_path("draft"));
let intent = app.navigation.activate(other_id);
let _task = app.handle_navigation(intent);
assert_eq!(
app.navigation.selected().map(TreeNodeId::path),
Some(Path::new("draft"))
);
let _task = app.update(Message::CancelDiscard);
assert!(app.confirmation.is_none());
assert_eq!(
@@ -1266,5 +1577,39 @@ mod tests {
repeat: false,
});
assert!(matches!(event_message(&save), Some(Message::Save)));
let tab = Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Tab),
modified_key: keyboard::Key::Named(keyboard::key::Named::Tab),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Tab),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::NONE,
text: None,
repeat: false,
});
assert!(matches!(
event_message(&tab),
Some(Message::TogglePaneFocus)
));
}
#[test]
fn pane_focus_and_storage_errors_are_handled_without_repository_access() {
let mut app = test_app(None);
assert_eq!(app.pane_focus, PaneFocus::Sidebar);
let _task = app.update(Message::TogglePaneFocus);
assert_eq!(app.pane_focus, PaneFocus::Content);
app.tree_generation = 7;
let completion = Arc::new(Mutex::new(Some(Err("injected tree failure".to_owned()))));
let _task = app.update(Message::TreeLoaded {
generation: 7,
completion,
});
assert_eq!(
app.tree_state,
TreeState::Error("injected tree failure".to_owned())
);
assert!(app.navigation.is_empty());
}
}