Open configured vault folders
This commit is contained in:
@@ -18,5 +18,12 @@ zeroize.workspace = true
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda.workspace = true
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
|
||||
rfd.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ashpd.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -7,6 +7,7 @@ pub enum UiAction {
|
||||
About,
|
||||
Settings,
|
||||
NewEntry,
|
||||
OpenFolder,
|
||||
OpenEntry,
|
||||
Save,
|
||||
CloseWindow,
|
||||
@@ -34,6 +35,7 @@ impl UiAction {
|
||||
Self::About => "about",
|
||||
Self::Settings => "settings",
|
||||
Self::NewEntry => "new-entry",
|
||||
Self::OpenFolder => "open-folder",
|
||||
Self::OpenEntry => "open-entry",
|
||||
Self::Save => "save",
|
||||
Self::CloseWindow => "close-window",
|
||||
@@ -104,6 +106,7 @@ pub struct ActionContext {
|
||||
pub editing: bool,
|
||||
pub dirty: bool,
|
||||
pub saving: bool,
|
||||
pub switching_vault: bool,
|
||||
pub focused_field: bool,
|
||||
pub focused_sensitive: bool,
|
||||
pub entry_path: bool,
|
||||
@@ -127,7 +130,13 @@ pub const ACTIONS: &[ActionSpec] = &[
|
||||
Some("⌘Q"),
|
||||
),
|
||||
spec(UiAction::NewEntry, MenuGroup::File, "New Entry", Some("⌘N")),
|
||||
spec(UiAction::OpenEntry, MenuGroup::File, "Open", Some("⌘O")),
|
||||
spec(
|
||||
UiAction::OpenFolder,
|
||||
MenuGroup::File,
|
||||
"Open Folder…",
|
||||
Some("⌘O"),
|
||||
),
|
||||
spec(UiAction::OpenEntry, MenuGroup::Entry, "Open Entry", None),
|
||||
spec(UiAction::Save, MenuGroup::File, "Save", Some("⌘S")),
|
||||
spec(
|
||||
UiAction::CloseWindow,
|
||||
@@ -221,20 +230,53 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
||||
UiAction::About | UiAction::Settings | UiAction::Help => true,
|
||||
// Entry creation is not valid until the dedicated workflow exists.
|
||||
UiAction::NewEntry => false,
|
||||
UiAction::OpenEntry => context.storage_ready && context.entry_path && !context.saving,
|
||||
UiAction::Save => context.unlocked && context.editing && context.dirty && !context.saving,
|
||||
UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => true,
|
||||
UiAction::OpenFolder => {
|
||||
context.storage_ready && !context.saving && !context.switching_vault
|
||||
}
|
||||
UiAction::OpenEntry => {
|
||||
context.storage_ready
|
||||
&& context.entry_path
|
||||
&& !context.saving
|
||||
&& !context.switching_vault
|
||||
}
|
||||
UiAction::Save => {
|
||||
context.unlocked
|
||||
&& context.editing
|
||||
&& context.dirty
|
||||
&& !context.saving
|
||||
&& !context.switching_vault
|
||||
}
|
||||
UiAction::CloseWindow | UiAction::Quit => !context.switching_vault,
|
||||
UiAction::Minimize => true,
|
||||
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => false,
|
||||
UiAction::CopyField => {
|
||||
context.unlocked && context.document_open && !context.editing && context.focused_field
|
||||
context.unlocked
|
||||
&& context.document_open
|
||||
&& !context.editing
|
||||
&& !context.switching_vault
|
||||
&& context.focused_field
|
||||
}
|
||||
UiAction::CopyEditedField => {
|
||||
context.unlocked && context.editing && !context.switching_vault && context.focused_field
|
||||
}
|
||||
UiAction::CopyEditedField => context.unlocked && context.editing && context.focused_field,
|
||||
UiAction::TogglePaneFocus => true,
|
||||
UiAction::Refresh => context.storage_ready && !context.tree_loading,
|
||||
UiAction::ReloadEntry => context.unlocked && context.document_open && !context.saving,
|
||||
UiAction::EditEntry => context.unlocked && context.document_open && !context.editing,
|
||||
UiAction::Refresh => {
|
||||
context.storage_ready && !context.tree_loading && !context.switching_vault
|
||||
}
|
||||
UiAction::ReloadEntry => {
|
||||
context.unlocked && context.document_open && !context.saving && !context.switching_vault
|
||||
}
|
||||
UiAction::EditEntry => {
|
||||
context.unlocked
|
||||
&& context.document_open
|
||||
&& !context.editing
|
||||
&& !context.switching_vault
|
||||
}
|
||||
UiAction::ToggleReveal => {
|
||||
context.unlocked && context.document_open && context.focused_sensitive
|
||||
context.unlocked
|
||||
&& context.document_open
|
||||
&& !context.switching_vault
|
||||
&& context.focused_sensitive
|
||||
}
|
||||
UiAction::Lock => context.unlocked,
|
||||
}
|
||||
@@ -253,7 +295,7 @@ pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> O
|
||||
match key.as_ref() {
|
||||
keyboard::Key::Character(",") => Some(UiAction::Settings),
|
||||
keyboard::Key::Character("n" | "N") => Some(UiAction::NewEntry),
|
||||
keyboard::Key::Character("o" | "O") => Some(UiAction::OpenEntry),
|
||||
keyboard::Key::Character("o" | "O") => Some(UiAction::OpenFolder),
|
||||
keyboard::Key::Character("s" | "S") => Some(UiAction::Save),
|
||||
keyboard::Key::Character("w" | "W") => Some(UiAction::CloseWindow),
|
||||
keyboard::Key::Character("q" | "Q") => Some(UiAction::Quit),
|
||||
@@ -285,6 +327,7 @@ mod tests {
|
||||
editing: true,
|
||||
dirty: true,
|
||||
saving: false,
|
||||
switching_vault: false,
|
||||
focused_field: true,
|
||||
focused_sensitive: true,
|
||||
entry_path: true,
|
||||
@@ -350,13 +393,37 @@ mod tests {
|
||||
..ready
|
||||
}
|
||||
));
|
||||
assert!(!enabled(
|
||||
UiAction::OpenFolder,
|
||||
ActionContext {
|
||||
switching_vault: true,
|
||||
..ready
|
||||
}
|
||||
));
|
||||
let switching = ActionContext {
|
||||
switching_vault: true,
|
||||
..ready
|
||||
};
|
||||
for action in [
|
||||
UiAction::Save,
|
||||
UiAction::CloseWindow,
|
||||
UiAction::Quit,
|
||||
UiAction::CopyField,
|
||||
UiAction::CopyEditedField,
|
||||
UiAction::Refresh,
|
||||
UiAction::ReloadEntry,
|
||||
UiAction::EditEntry,
|
||||
UiAction::ToggleReveal,
|
||||
] {
|
||||
assert!(!enabled(action, switching), "{action:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conventional_shortcuts_resolve_to_the_registered_actions() {
|
||||
let primary = keyboard::Modifiers::COMMAND;
|
||||
for (key, expected) in [
|
||||
("o", UiAction::OpenEntry),
|
||||
("o", UiAction::OpenFolder),
|
||||
("s", UiAction::Save),
|
||||
("f", UiAction::Find),
|
||||
("n", UiAction::NewEntry),
|
||||
|
||||
69
apps/desktop/src/folder_picker.rs
Normal file
69
apps/desktop/src/folder_picker.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
//! Platform folder pickers without helper processes.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
|
||||
let mut dialog = rfd::AsyncFileDialog::new().set_title("Open Password Store");
|
||||
if let Some(initial) = initial.filter(|path| path.is_dir()) {
|
||||
dialog = dialog.set_directory(initial);
|
||||
}
|
||||
Ok(dialog
|
||||
.pick_folder()
|
||||
.await
|
||||
.map(|folder| folder.path().to_owned()))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
|
||||
use ashpd::{
|
||||
PortalError,
|
||||
desktop::{file_chooser::SelectedFiles, request::ResponseError},
|
||||
};
|
||||
|
||||
let mut request = SelectedFiles::open_file()
|
||||
.title("Open Password Store")
|
||||
.accept_label("Open")
|
||||
.modal(true)
|
||||
.multiple(false)
|
||||
.directory(true);
|
||||
if let Some(initial) = initial.filter(|path| path.is_dir()) {
|
||||
request = request
|
||||
.current_folder(initial)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
let response = request.send().await.and_then(|request| request.response());
|
||||
let selected = match response {
|
||||
Ok(selected) => selected,
|
||||
Err(ashpd::Error::Response(ResponseError::Cancelled))
|
||||
| Err(ashpd::Error::Portal(PortalError::Cancelled(_))) => return Ok(None),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
selected
|
||||
.uris()
|
||||
.first()
|
||||
.ok_or_else(|| "folder portal returned no selection".to_owned())
|
||||
.and_then(|uri| file_uri_path(uri.as_str()))
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn file_uri_path(uri: &str) -> Result<PathBuf, String> {
|
||||
let uri = url::Url::parse(uri).map_err(|_| "folder portal returned an invalid URI")?;
|
||||
uri.to_file_path()
|
||||
.map_err(|()| "folder portal returned a non-file URI".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn portal_file_uris_are_decoded_and_other_schemes_are_rejected() {
|
||||
assert_eq!(
|
||||
file_uri_path("file:///tmp/password%20store").expect("file URI"),
|
||||
PathBuf::from("/tmp/password store")
|
||||
);
|
||||
assert!(file_uri_path("https://example.test/store").is_err());
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -116,7 +116,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
|
||||
let (modifiers, code) = match action {
|
||||
UiAction::Settings => (command, Code::Comma),
|
||||
UiAction::NewEntry => (command, Code::KeyN),
|
||||
UiAction::OpenEntry => (command, Code::KeyO),
|
||||
UiAction::OpenFolder => (command, Code::KeyO),
|
||||
UiAction::Save => (command, Code::KeyS),
|
||||
UiAction::CloseWindow => (command, Code::KeyW),
|
||||
UiAction::Quit => (command, Code::KeyQ),
|
||||
@@ -132,6 +132,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
|
||||
| UiAction::CopyField
|
||||
| UiAction::CopyEditedField
|
||||
| UiAction::TogglePaneFocus
|
||||
| UiAction::OpenEntry
|
||||
| UiAction::ReloadEntry
|
||||
| UiAction::EditEntry
|
||||
| UiAction::ToggleReveal
|
||||
|
||||
Reference in New Issue
Block a user