Open configured vault folders

This commit is contained in:
2026-08-10 17:21:35 +02:00
parent c301d9a3df
commit 491d7b1557
11 changed files with 712 additions and 19 deletions

View File

@@ -3,11 +3,13 @@
mod action;
mod editor;
mod folder_picker;
#[cfg(target_os = "macos")]
mod native_menu;
mod navigation;
use std::{
path::PathBuf,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
@@ -58,6 +60,11 @@ enum Message {
ToggleMenu(MenuGroup),
DismissUtility,
WindowResolved(UiAction, Option<window::Id>),
FolderPicked(Result<Option<PathBuf>, String>),
VaultSwitched {
generation: u64,
result: Box<Result<DesktopStorage, String>>,
},
#[cfg(target_os = "macos")]
PollNativeMenu,
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
@@ -161,6 +168,7 @@ struct App {
authentication_generation: u64,
operation_generation: u64,
tree_generation: u64,
vault_generation: u64,
panes: pane_grid::State<PaneKind>,
pane_focus: PaneFocus,
navigation: NavigationTree,
@@ -170,6 +178,7 @@ struct App {
editor: Option<EntryEditor>,
content_mode: ContentMode,
saving: bool,
switching_vault: bool,
confirmation: Option<PendingAction>,
after_save: Option<PendingAction>,
generate_confirmation: Option<EntryFieldId>,
@@ -216,6 +225,7 @@ enum TreeState {
#[derive(Clone, Debug, Eq, PartialEq)]
enum PendingAction {
OpenVault(PathBuf),
OpenEntry(String),
Reload(String),
CloseWindow(window::Id),
@@ -259,6 +269,7 @@ impl App {
authentication_generation: 0,
operation_generation: 0,
tree_generation: 0,
vault_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical,
ratio: 0.28,
@@ -273,6 +284,7 @@ impl App {
editor: None,
content_mode: ContentMode::Viewer,
saving: false,
switching_vault: false,
confirmation: None,
after_save: None,
generate_confirmation: None,
@@ -321,6 +333,53 @@ impl App {
_ => Task::none(),
};
}
Message::FolderPicked(result) => match result {
Ok(Some(path)) => return self.request_action(PendingAction::OpenVault(path)),
Ok(None) => {
self.status = "Open Folder cancelled; current vault unchanged.".to_owned();
}
Err(error) => {
self.status =
format!("Folder picker failed: {error}. Current vault unchanged.");
}
},
Message::VaultSwitched { generation, result } => {
if generation != self.vault_generation {
return Task::none();
}
self.switching_vault = false;
match *result {
Ok(storage) => {
let vault = storage.vault().display().to_string();
self.operation_generation = self.operation_generation.wrapping_add(1);
self.authentication_generation =
self.authentication_generation.wrapping_add(1);
self.sensitive.clear();
if let Some(session) = &self.session {
let _ignored = session.manual_lock();
}
self.storage = Some(storage);
self.handle = None;
self.authentication = AuthenticationView::Locked;
self.editor = None;
self.content_mode = ContentMode::Viewer;
self.navigation = NavigationTree::default();
self.tree_state = TreeState::Loading;
self.entry_path.clear();
self.confirmation = None;
self.after_save = None;
self.after_authentication = None;
self.generate_confirmation = None;
self.conflict = false;
self.status = format!("Opened password store at {vault}.");
return self.begin_tree_refresh();
}
Err(error) => {
self.status =
format!("Open Folder failed: {error}. Current vault unchanged.");
}
}
}
#[cfg(target_os = "macos")]
Message::PollNativeMenu => {
if let Some(action) = self.native_menu.as_ref().and_then(NativeMenu::poll) {
@@ -648,6 +707,7 @@ impl App {
editing: self.content_mode == ContentMode::Editor,
dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty),
saving: self.saving,
switching_vault: self.switching_vault,
focused_field: focused.is_some(),
focused_sensitive: focused
.is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive),
@@ -680,6 +740,14 @@ impl App {
UiAction::About => self.utility = Some(UtilityView::About),
UiAction::Settings => self.utility = Some(UtilityView::Settings),
UiAction::Help => self.utility = Some(UtilityView::Help),
UiAction::OpenFolder => {
let initial = self
.storage
.as_ref()
.map(|storage| storage.vault().to_owned());
self.status = "Choose a password-store folder…".to_owned();
return Task::perform(folder_picker::pick_folder(initial), Message::FolderPicked);
}
UiAction::OpenEntry => return self.update(Message::OpenEntry),
UiAction::Save => return self.begin_save(),
UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => {
@@ -746,6 +814,11 @@ impl App {
}
fn request_action(&mut self, action: PendingAction) -> Task<Message> {
if self.switching_vault && matches!(action, PendingAction::CloseWindow(_)) {
self.status =
"Wait for vault validation to finish before closing IronStorage.".to_owned();
return Task::none();
}
if self.saving {
self.after_save = Some(action);
self.status = "Waiting for the active save to finish…".to_owned();
@@ -764,6 +837,7 @@ impl App {
fn execute_action(&mut self, action: PendingAction) -> Task<Message> {
match action {
PendingAction::OpenVault(path) => self.begin_vault_switch(path),
PendingAction::OpenEntry(entry) | PendingAction::Reload(entry) => {
self.editor = None;
self.content_mode = ContentMode::Viewer;
@@ -776,6 +850,27 @@ impl App {
}
}
fn begin_vault_switch(&mut self, path: PathBuf) -> Task<Message> {
let Some(storage) = self.storage.clone() else {
self.status = "Load a valid shared configuration before opening a folder.".to_owned();
return Task::none();
};
self.vault_generation = self.vault_generation.wrapping_add(1);
let generation = self.vault_generation;
self.switching_vault = true;
self.status = format!("Validating password store at {}", path.display());
Task::perform(
async move {
Box::new(
storage
.switch_vault(&path)
.map_err(|error| error.to_string()),
)
},
move |result| Message::VaultSwitched { generation, result },
)
}
fn begin_open(&mut self, entry: String) -> Task<Message> {
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
self.after_authentication = Some(PendingAction::OpenEntry(entry));
@@ -997,6 +1092,14 @@ impl App {
column![
row![
text(authentication),
text(
self.storage
.as_ref()
.map_or("No configured vault".to_owned(), |storage| {
storage.vault().display().to_string()
})
)
.size(13),
text(&self.status).size(14),
text("Tab changes pane focus").size(12),
]
@@ -1074,7 +1177,23 @@ fn utility_view(app: &App, utility: UtilityView) -> Element<'_, Message> {
UtilityView::Settings => {
content = content.push(text("Settings").size(28));
if let Some(storage) = &app.storage {
let editor = storage.configured_editor().map_or_else(
|| "Environment or built-in fallback".to_owned(),
|editor| {
std::iter::once(editor.program())
.chain(editor.arguments().iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" ")
},
);
content = content
.push(text(format!(
"Configuration: {}",
storage.config_source().display()
)))
.push(text(format!("Vault: {}", storage.vault().display())))
.push(text(format!("Default key: {}", storage.default_key())))
.push(text(format!("Editor: {editor}")))
.push(text(format!(
"Authentication inactivity timeout: {} seconds",
storage.authentication_timeout().duration().as_secs()
@@ -1202,12 +1321,19 @@ fn content_view(app: &App) -> Element<'_, Message> {
.on_input(Message::EntryPathChanged)
.on_submit(Message::Action(UiAction::OpenEntry)),
button("Open").on_press(Message::Action(UiAction::OpenEntry)),
button("Open Folder…").on_press(Message::Action(UiAction::OpenFolder)),
button("Reload").on_press(Message::Action(UiAction::ReloadEntry)),
button("Lock").on_press(Message::Action(UiAction::Lock)),
]
.spacing(8);
let body: Element<'_, Message> = if !authentication_allows_content(&app.authentication) {
let body: Element<'_, Message> = if app.switching_vault {
container(text(
"Validating the selected password store and shared configuration…",
))
.center(Length::Fill)
.into()
} else if !authentication_allows_content(&app.authentication) {
container(text(
"Protected entry content is locked. Select an entry to authenticate and open it.",
))
@@ -1458,6 +1584,7 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
fn confirmation_view(action: &PendingAction) -> Element<'_, Message> {
let description = match action {
PendingAction::OpenVault(path) => format!("Open {}", path.display()),
PendingAction::OpenEntry(entry) => format!("Open {entry}"),
PendingAction::Reload(entry) => format!("Reload {entry}"),
PendingAction::CloseWindow(_) => "Close IronStorage".to_owned(),
@@ -1790,6 +1917,7 @@ mod tests {
authentication_generation: 0,
operation_generation: 0,
tree_generation: 0,
vault_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical,
ratio: 0.28,
@@ -1804,6 +1932,7 @@ mod tests {
editor,
content_mode: ContentMode::Viewer,
saving: false,
switching_vault: false,
confirmation: None,
after_save: None,
generate_confirmation: None,
@@ -1968,13 +2097,15 @@ mod tests {
editor.add_after(None).expect("line");
assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm);
for action in [
PendingAction::OpenVault(PathBuf::from("/selected-vault")),
PendingAction::OpenEntry("other".to_owned()),
PendingAction::Reload("draft".to_owned()),
PendingAction::CloseWindow(window::Id::unique()),
] {
assert!(matches!(
action,
PendingAction::OpenEntry(_)
PendingAction::OpenVault(_)
| PendingAction::OpenEntry(_)
| PendingAction::Reload(_)
| PendingAction::CloseWindow(_)
));
@@ -1984,6 +2115,18 @@ mod tests {
let draft = editor.document().serialize().expose().to_vec();
let mut app = test_app(Some(editor));
let _task = app.update(Message::FolderPicked(Ok(None)));
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
let selected_vault = PathBuf::from("/selected-vault");
let _task = app.update(Message::FolderPicked(Ok(Some(selected_vault.clone()))));
assert_eq!(
app.confirmation,
Some(PendingAction::OpenVault(selected_vault))
);
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
let _task = app.update(Message::CancelDiscard);
assert!(app.confirmation.is_none());
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![
@@ -2044,6 +2187,49 @@ mod tests {
let _task = app.update(Message::WindowResolved(UiAction::CloseWindow, Some(id)));
assert_eq!(app.confirmation, Some(PendingAction::CloseWindow(id)));
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
app.confirmation = None;
app.switching_vault = true;
let _task = app.update(Message::RequestClose(id));
assert!(app.confirmation.is_none());
assert!(app.status.contains("Wait for vault validation"));
}
#[test]
fn vault_switch_result_replaces_state_only_after_storage_success() {
let (_temporary, storage) = fixture_storage();
let mut failed = test_app(None);
failed.storage = Some(storage.clone());
failed.switching_vault = true;
failed.vault_generation = 4;
let original = failed.storage.as_ref().expect("storage").vault().to_owned();
let _task = failed.update(Message::VaultSwitched {
generation: 4,
result: Box::new(Err("injected failure".to_owned())),
});
assert_eq!(failed.storage.as_ref().expect("storage").vault(), original);
assert!(!failed.switching_vault);
assert!(failed.status.contains("Current vault unchanged"));
let mut editor = empty_editor(&storage, "draft");
editor.add_after(None).expect("dirty line");
let mut succeeded = test_app(Some(editor));
succeeded.authentication = AuthenticationView::Unlocked(Duration::from_secs(60));
succeeded.switching_vault = true;
succeeded.vault_generation = 5;
succeeded.operation_generation = 8;
let _task = succeeded.update(Message::VaultSwitched {
generation: 5,
result: Box::new(Ok(storage)),
});
assert!(succeeded.editor.is_none());
assert!(matches!(
succeeded.authentication,
AuthenticationView::Locked
));
assert_eq!(succeeded.tree_state, TreeState::Loading);
assert!(!succeeded.switching_vault);
assert_eq!(succeeded.operation_generation, 9);
}
#[test]