Add desktop navigation tree
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
#![deny(clippy::disallowed_types)]
|
#![deny(clippy::disallowed_types)]
|
||||||
|
|
||||||
mod editor;
|
mod editor;
|
||||||
|
mod navigation;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
sync::{
|
sync::{
|
||||||
@@ -14,8 +15,8 @@ use std::{
|
|||||||
|
|
||||||
use editor::EntryEditor;
|
use editor::EntryEditor;
|
||||||
use iced::{
|
use iced::{
|
||||||
Element, Event, Length, Subscription, Task, event, keyboard, mouse, time, touch,
|
Element, Event, Length, Size, Subscription, Task, event, keyboard, mouse, time, touch,
|
||||||
widget::{button, column, container, row, scrollable, text, text_input},
|
widget::{button, column, container, pane_grid, row, scrollable, text, text_input},
|
||||||
window,
|
window,
|
||||||
};
|
};
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
@@ -29,18 +30,30 @@ use ironstorage::{
|
|||||||
generate::GeneratorConfig,
|
generate::GeneratorConfig,
|
||||||
git::{AutomaticEntryCommitter, GitIdentity},
|
git::{AutomaticEntryCommitter, GitIdentity},
|
||||||
presentation::{ClipboardWait, NativeClipboardManager},
|
presentation::{ClipboardWait, NativeClipboardManager},
|
||||||
|
read::{TreeModel, TreeNodeId},
|
||||||
repository::{Repository, SecretBytes},
|
repository::{Repository, SecretBytes},
|
||||||
secret_store::{SecretProtectionPolicy, SecretStoreBackend},
|
secret_store::{SecretProtectionPolicy, SecretStoreBackend},
|
||||||
write::{WriteError, WriteOutcome},
|
write::{WriteError, WriteOutcome},
|
||||||
};
|
};
|
||||||
|
use navigation::{NavigationIntent, NavigationKey, NavigationTree};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
type OpenCompletion = Arc<Mutex<Option<Result<EntryDocument, String>>>>;
|
type OpenCompletion = Arc<Mutex<Option<Result<EntryDocument, String>>>>;
|
||||||
type SaveCompletion = Arc<Mutex<Option<(EntryEditor, Result<WriteOutcome, SaveFailure>)>>>;
|
type SaveCompletion = Arc<Mutex<Option<(EntryEditor, Result<WriteOutcome, SaveFailure>)>>>;
|
||||||
|
type TreeCompletion = Arc<Mutex<Option<Result<TreeModel, String>>>>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum Message {
|
enum Message {
|
||||||
StartupLoaded(Box<Result<(Config, NativeAuthenticationSession, KeyInfo), String>>),
|
StartupLoaded(Box<Result<(Config, NativeAuthenticationSession, KeyInfo), String>>),
|
||||||
|
TreeLoaded {
|
||||||
|
generation: u64,
|
||||||
|
completion: TreeCompletion,
|
||||||
|
},
|
||||||
|
RefreshTree,
|
||||||
|
SidebarActivate(TreeNodeId),
|
||||||
|
SidebarNavigate(NavigationKey),
|
||||||
|
TogglePaneFocus,
|
||||||
|
PaneResized(pane_grid::ResizeEvent),
|
||||||
EntryPathChanged(String),
|
EntryPathChanged(String),
|
||||||
OpenEntry,
|
OpenEntry,
|
||||||
OpenFinished {
|
OpenFinished {
|
||||||
@@ -131,6 +144,11 @@ struct App {
|
|||||||
sensitive: SensitiveUiState,
|
sensitive: SensitiveUiState,
|
||||||
authentication_generation: u64,
|
authentication_generation: u64,
|
||||||
operation_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>,
|
after_authentication: Option<PendingAction>,
|
||||||
entry_path: String,
|
entry_path: String,
|
||||||
editor: Option<EntryEditor>,
|
editor: Option<EntryEditor>,
|
||||||
@@ -142,6 +160,25 @@ struct App {
|
|||||||
status: String,
|
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)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
enum PendingAction {
|
enum PendingAction {
|
||||||
OpenEntry(String),
|
OpenEntry(String),
|
||||||
@@ -180,6 +217,11 @@ fn main() -> iced::Result {
|
|||||||
.title(ironstorage::PRODUCT_NAME)
|
.title(ironstorage::PRODUCT_NAME)
|
||||||
.subscription(App::subscription)
|
.subscription(App::subscription)
|
||||||
.exit_on_close_request(false)
|
.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()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +237,16 @@ impl App {
|
|||||||
sensitive: SensitiveUiState::default(),
|
sensitive: SensitiveUiState::default(),
|
||||||
authentication_generation: 0,
|
authentication_generation: 0,
|
||||||
operation_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,
|
after_authentication: None,
|
||||||
entry_path: String::new(),
|
entry_path: String::new(),
|
||||||
editor: None,
|
editor: None,
|
||||||
@@ -221,14 +273,71 @@ impl App {
|
|||||||
self.authentication = AuthenticationView::Locked;
|
self.authentication = AuthenticationView::Locked;
|
||||||
self.status = "Entry names remain available while protected content is locked."
|
self.status = "Entry names remain available while protected content is locked."
|
||||||
.to_owned();
|
.to_owned();
|
||||||
|
return self.begin_tree_refresh();
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
self.tree_state = TreeState::Error(error.clone());
|
||||||
self.authentication = AuthenticationView::Unavailable(error.clone());
|
self.authentication = AuthenticationView::Unavailable(error.clone());
|
||||||
self.status = error;
|
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 => {
|
Message::OpenEntry => {
|
||||||
|
self.pane_focus = PaneFocus::Content;
|
||||||
let entry = self.entry_path.trim().to_owned();
|
let entry = self.entry_path.trim().to_owned();
|
||||||
if entry.is_empty() {
|
if entry.is_empty() {
|
||||||
self.status = "Enter an entry path first.".to_owned();
|
self.status = "Enter an entry path first.".to_owned();
|
||||||
@@ -250,6 +359,8 @@ impl App {
|
|||||||
match result {
|
match result {
|
||||||
Ok(document) => {
|
Ok(document) => {
|
||||||
self.entry_path = entry.clone();
|
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.editor = Some(EntryEditor::new(document));
|
||||||
self.conflict = false;
|
self.conflict = false;
|
||||||
self.status = format!("Editing {entry}");
|
self.status = format!("Editing {entry}");
|
||||||
@@ -285,6 +396,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::FieldChanged(id, value) => {
|
Message::FieldChanged(id, value) => {
|
||||||
|
self.pane_focus = PaneFocus::Content;
|
||||||
self.edit(|editor| editor.update_raw(id, value.as_bytes()));
|
self.edit(|editor| editor.update_raw(id, value.as_bytes()));
|
||||||
}
|
}
|
||||||
Message::AddAfter(id) => self.edit(|editor| editor.add_after(id)),
|
Message::AddAfter(id) => self.edit(|editor| editor.add_after(id)),
|
||||||
@@ -422,6 +534,37 @@ impl App {
|
|||||||
Task::none()
|
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> {
|
fn request_action(&mut self, action: PendingAction) -> Task<Message> {
|
||||||
if self.saving {
|
if self.saving {
|
||||||
self.after_save = Some(action);
|
self.after_save = Some(action);
|
||||||
@@ -429,6 +572,9 @@ impl App {
|
|||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
if dirty_decision(self.editor.as_ref()) == DirtyDecision::Confirm {
|
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);
|
self.confirmation = Some(action);
|
||||||
Task::none()
|
Task::none()
|
||||||
} else {
|
} else {
|
||||||
@@ -597,39 +743,32 @@ impl App {
|
|||||||
}
|
}
|
||||||
AuthenticationView::Unavailable(error) => format!("Unavailable · {error}"),
|
AuthenticationView::Unavailable(error) => format!("Unavailable · {error}"),
|
||||||
};
|
};
|
||||||
let lock = button("Lock").on_press(Message::Lock);
|
let panes = pane_grid(&self.panes, |_pane, kind, _maximized| {
|
||||||
let open = row![
|
pane_grid::Content::new(match kind {
|
||||||
text_input("Entry path", &self.entry_path)
|
PaneKind::Sidebar => sidebar_view(
|
||||||
.on_input(Message::EntryPathChanged)
|
&self.navigation,
|
||||||
.on_submit(Message::OpenEntry),
|
&self.tree_state,
|
||||||
button("Edit").on_press(Message::OpenEntry),
|
self.pane_focus == PaneFocus::Sidebar,
|
||||||
button("Reload").on_press(Message::RequestReload),
|
),
|
||||||
lock,
|
PaneKind::Content => content_view(self),
|
||||||
]
|
})
|
||||||
.spacing(8);
|
})
|
||||||
|
.spacing(1)
|
||||||
let body: Element<'_, Message> = if self.saving {
|
.min_size(220)
|
||||||
container(text("Saving without discarding the draft…"))
|
.on_resize(8, Message::PaneResized);
|
||||||
.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()
|
|
||||||
};
|
|
||||||
|
|
||||||
container(
|
container(
|
||||||
column![
|
column![
|
||||||
row![text(authentication), text(&self.status).size(14)].spacing(16),
|
row![
|
||||||
open,
|
text(authentication),
|
||||||
body,
|
text(&self.status).size(14),
|
||||||
|
text("Tab changes pane focus").size(12),
|
||||||
|
]
|
||||||
|
.spacing(16)
|
||||||
|
.padding(10),
|
||||||
|
panes,
|
||||||
]
|
]
|
||||||
.spacing(12)
|
.height(Length::Fill),
|
||||||
.padding(16),
|
|
||||||
)
|
)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(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 {
|
impl SaveFailure {
|
||||||
fn storage(message: String) -> Self {
|
fn storage(message: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -804,6 +1064,14 @@ fn load_document(
|
|||||||
.map_err(|error| error.to_string())
|
.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> {
|
fn save_document(config: &Config, editor: &EntryEditor) -> Result<WriteOutcome, SaveFailure> {
|
||||||
let repository = Repository::open(config.vault())
|
let repository = Repository::open(config.vault())
|
||||||
.map_err(|error| SaveFailure::storage(error.to_string()))?;
|
.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> {
|
fn event_message(event: &Event) -> Option<Message> {
|
||||||
if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event
|
if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
|
||||||
&& modifiers.command()
|
if modifiers.command() && matches!(key.as_ref(), keyboard::Key::Character("s" | "S")) {
|
||||||
&& matches!(key.as_ref(), keyboard::Key::Character("s" | "S"))
|
return Some(Message::Save);
|
||||||
{
|
}
|
||||||
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)
|
is_deliberate_activity(event).then_some(Message::UserActivity)
|
||||||
}
|
}
|
||||||
@@ -1085,6 +1366,16 @@ mod tests {
|
|||||||
sensitive: SensitiveUiState::default(),
|
sensitive: SensitiveUiState::default(),
|
||||||
authentication_generation: 0,
|
authentication_generation: 0,
|
||||||
operation_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,
|
after_authentication: None,
|
||||||
entry_path: String::new(),
|
entry_path: String::new(),
|
||||||
editor,
|
editor,
|
||||||
@@ -1190,7 +1481,27 @@ mod tests {
|
|||||||
|
|
||||||
let draft = editor.document().serialize().expose().to_vec();
|
let draft = editor.document().serialize().expose().to_vec();
|
||||||
let mut app = test_app(Some(editor));
|
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);
|
let _task = app.update(Message::CancelDiscard);
|
||||||
assert!(app.confirmation.is_none());
|
assert!(app.confirmation.is_none());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1266,5 +1577,39 @@ mod tests {
|
|||||||
repeat: false,
|
repeat: false,
|
||||||
});
|
});
|
||||||
assert!(matches!(event_message(&save), Some(Message::Save)));
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
390
apps/desktop/src/navigation.rs
Normal file
390
apps/desktop/src/navigation.rs
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
//! Desktop navigation over storage-owned tree models and identities.
|
||||||
|
|
||||||
|
use std::{collections::BTreeSet, path::Path};
|
||||||
|
|
||||||
|
use ironstorage::read::{TreeModel, TreeNode, TreeNodeId, TreeNodeIndicators, TreeNodeKind};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum NavigationKey {
|
||||||
|
Previous,
|
||||||
|
Next,
|
||||||
|
Collapse,
|
||||||
|
Expand,
|
||||||
|
Activate,
|
||||||
|
First,
|
||||||
|
Last,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum NavigationIntent {
|
||||||
|
None,
|
||||||
|
OpenEntry(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
struct NavigationNode {
|
||||||
|
id: TreeNodeId,
|
||||||
|
name: String,
|
||||||
|
indicators: TreeNodeIndicators,
|
||||||
|
children: Vec<NavigationNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NavigationNode {
|
||||||
|
fn from_storage(node: &TreeNode) -> Self {
|
||||||
|
Self {
|
||||||
|
id: node.id().clone(),
|
||||||
|
name: node.name().to_owned(),
|
||||||
|
indicators: node.indicators(),
|
||||||
|
children: node.children().iter().map(Self::from_storage).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct NavigationRow {
|
||||||
|
pub id: TreeNodeId,
|
||||||
|
pub name: String,
|
||||||
|
pub indicators: TreeNodeIndicators,
|
||||||
|
pub depth: usize,
|
||||||
|
pub expanded: bool,
|
||||||
|
pub has_children: bool,
|
||||||
|
parent: Option<TreeNodeId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct NavigationTree {
|
||||||
|
nodes: Vec<NavigationNode>,
|
||||||
|
expanded: BTreeSet<TreeNodeId>,
|
||||||
|
selected: Option<TreeNodeId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NavigationTree {
|
||||||
|
pub fn replace(&mut self, model: &TreeModel) {
|
||||||
|
self.nodes = model
|
||||||
|
.children()
|
||||||
|
.iter()
|
||||||
|
.map(NavigationNode::from_storage)
|
||||||
|
.collect();
|
||||||
|
let valid = collect_ids(&self.nodes);
|
||||||
|
self.expanded.retain(|id| valid.contains(id));
|
||||||
|
if !self.selected.as_ref().is_some_and(|id| valid.contains(id)) {
|
||||||
|
self.selected = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.nodes.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> Option<&TreeNodeId> {
|
||||||
|
self.selected.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rows(&self) -> Vec<NavigationRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
flatten(&self.nodes, 0, None, &self.expanded, &mut rows);
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_ratio(&self) -> f32 {
|
||||||
|
let rows = self.rows();
|
||||||
|
let Some(index) = selected_index(&rows, self.selected.as_ref()) else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
index as f32 / rows.len().saturating_sub(1).max(1) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn activate(&mut self, id: TreeNodeId) -> NavigationIntent {
|
||||||
|
if !self.rows().iter().any(|row| row.id == id) {
|
||||||
|
return NavigationIntent::None;
|
||||||
|
}
|
||||||
|
self.selected = Some(id);
|
||||||
|
self.activate_selected()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn navigate(&mut self, key: NavigationKey) -> NavigationIntent {
|
||||||
|
match key {
|
||||||
|
NavigationKey::Previous => self.move_by(-1),
|
||||||
|
NavigationKey::Next => self.move_by(1),
|
||||||
|
NavigationKey::Collapse => self.collapse_or_parent(),
|
||||||
|
NavigationKey::Expand => self.expand_or_child(),
|
||||||
|
NavigationKey::Activate => return self.activate_selected(),
|
||||||
|
NavigationKey::First => self.select_edge(false),
|
||||||
|
NavigationKey::Last => self.select_edge(true),
|
||||||
|
}
|
||||||
|
NavigationIntent::None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_entry_path(&mut self, path: &str) -> bool {
|
||||||
|
let mut lineage = Vec::new();
|
||||||
|
if !find_lineage(&self.nodes, path, &mut lineage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(selected) = lineage.pop() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if selected.kind() != TreeNodeKind::Entry {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.expanded.extend(lineage);
|
||||||
|
self.selected = Some(selected);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate_selected(&mut self) -> NavigationIntent {
|
||||||
|
let Some(selected) = self.selected.clone() else {
|
||||||
|
return NavigationIntent::None;
|
||||||
|
};
|
||||||
|
if selected.is_directory() {
|
||||||
|
if !self.expanded.remove(&selected) {
|
||||||
|
self.expanded.insert(selected);
|
||||||
|
}
|
||||||
|
NavigationIntent::None
|
||||||
|
} else {
|
||||||
|
NavigationIntent::OpenEntry(
|
||||||
|
selected
|
||||||
|
.entry()
|
||||||
|
.expect("non-directory tree identity is an entry")
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_by(&mut self, amount: isize) {
|
||||||
|
let rows = self.rows();
|
||||||
|
if rows.is_empty() {
|
||||||
|
self.selected = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let destination = selected_index(&rows, self.selected.as_ref()).map_or_else(
|
||||||
|
|| usize::from(amount < 0) * (rows.len() - 1),
|
||||||
|
|index| index.saturating_add_signed(amount).min(rows.len() - 1),
|
||||||
|
);
|
||||||
|
self.selected = Some(rows[destination].id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_edge(&mut self, last: bool) {
|
||||||
|
let rows = self.rows();
|
||||||
|
self.selected = rows
|
||||||
|
.get(if last {
|
||||||
|
rows.len().saturating_sub(1)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
})
|
||||||
|
.map(|row| row.id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collapse_or_parent(&mut self) {
|
||||||
|
let Some(selected) = self.selected.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if selected.is_directory() && self.expanded.remove(&selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rows = self.rows();
|
||||||
|
self.selected = selected_index(&rows, Some(&selected))
|
||||||
|
.and_then(|index| rows[index].parent.clone())
|
||||||
|
.or(Some(selected));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_or_child(&mut self) {
|
||||||
|
let rows = self.rows();
|
||||||
|
let Some(index) = selected_index(&rows, self.selected.as_ref()) else {
|
||||||
|
self.select_edge(false);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let row = &rows[index];
|
||||||
|
if !row.id.is_directory() || !row.has_children {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.expanded.insert(row.id.clone()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rows = self.rows();
|
||||||
|
if let Some(child) = rows.get(index + 1).filter(|child| child.depth > row.depth) {
|
||||||
|
self.selected = Some(child.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn replace_test_nodes(&mut self, nodes: Vec<TestNode>) {
|
||||||
|
self.nodes = nodes.into_iter().map(TestNode::into_navigation).collect();
|
||||||
|
let valid = collect_ids(&self.nodes);
|
||||||
|
self.expanded.retain(|id| valid.contains(id));
|
||||||
|
if !self.selected.as_ref().is_some_and(|id| valid.contains(id)) {
|
||||||
|
self.selected = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten(
|
||||||
|
nodes: &[NavigationNode],
|
||||||
|
depth: usize,
|
||||||
|
parent: Option<&TreeNodeId>,
|
||||||
|
expanded: &BTreeSet<TreeNodeId>,
|
||||||
|
rows: &mut Vec<NavigationRow>,
|
||||||
|
) {
|
||||||
|
for node in nodes {
|
||||||
|
let is_expanded = expanded.contains(&node.id);
|
||||||
|
rows.push(NavigationRow {
|
||||||
|
id: node.id.clone(),
|
||||||
|
name: node.name.clone(),
|
||||||
|
indicators: node.indicators,
|
||||||
|
depth,
|
||||||
|
expanded: is_expanded,
|
||||||
|
has_children: !node.children.is_empty(),
|
||||||
|
parent: parent.cloned(),
|
||||||
|
});
|
||||||
|
if node.id.is_directory() && is_expanded {
|
||||||
|
flatten(&node.children, depth + 1, Some(&node.id), expanded, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_index(rows: &[NavigationRow], selected: Option<&TreeNodeId>) -> Option<usize> {
|
||||||
|
let selected = selected?;
|
||||||
|
rows.iter().position(|row| &row.id == selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_ids(nodes: &[NavigationNode]) -> BTreeSet<TreeNodeId> {
|
||||||
|
fn visit(nodes: &[NavigationNode], ids: &mut BTreeSet<TreeNodeId>) {
|
||||||
|
for node in nodes {
|
||||||
|
ids.insert(node.id.clone());
|
||||||
|
visit(&node.children, ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ids = BTreeSet::new();
|
||||||
|
visit(nodes, &mut ids);
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_lineage(nodes: &[NavigationNode], path: &str, lineage: &mut Vec<TreeNodeId>) -> bool {
|
||||||
|
for node in nodes {
|
||||||
|
lineage.push(node.id.clone());
|
||||||
|
if node
|
||||||
|
.id
|
||||||
|
.entry()
|
||||||
|
.is_some_and(|entry| entry.as_path() == Path::new(path))
|
||||||
|
|| find_lineage(&node.children, path, lineage)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let _ = lineage.pop();
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) struct TestNode {
|
||||||
|
pub id: TreeNodeId,
|
||||||
|
pub name: String,
|
||||||
|
pub children: Vec<TestNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl TestNode {
|
||||||
|
fn into_navigation(self) -> NavigationNode {
|
||||||
|
NavigationNode {
|
||||||
|
id: self.id,
|
||||||
|
name: self.name,
|
||||||
|
indicators: TreeNodeIndicators::default(),
|
||||||
|
children: self
|
||||||
|
.children
|
||||||
|
.into_iter()
|
||||||
|
.map(Self::into_navigation)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use ironstorage::repository::{DirectoryPath, EntryPath};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn directory(path: &str, children: Vec<TestNode>) -> TestNode {
|
||||||
|
TestNode {
|
||||||
|
id: TreeNodeId::Directory(DirectoryPath::parse(path).expect("directory path")),
|
||||||
|
name: path.rsplit('/').next().unwrap_or(path).to_owned(),
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(path: &str) -> TestNode {
|
||||||
|
TestNode {
|
||||||
|
id: TreeNodeId::Entry(EntryPath::parse(path).expect("entry path")),
|
||||||
|
name: path.rsplit('/').next().unwrap_or(path).to_owned(),
|
||||||
|
children: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn populated() -> Vec<TestNode> {
|
||||||
|
vec![
|
||||||
|
directory(
|
||||||
|
"personal",
|
||||||
|
vec![
|
||||||
|
entry("personal/email"),
|
||||||
|
entry("personal/a very long entry name that remains complete"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
directory("work", vec![entry("work/server")]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyboard_and_mouse_navigation_expand_folders_and_open_only_entries() {
|
||||||
|
let mut tree = NavigationTree::default();
|
||||||
|
tree.replace_test_nodes(populated());
|
||||||
|
assert_eq!(tree.navigate(NavigationKey::Next), NavigationIntent::None);
|
||||||
|
assert_eq!(
|
||||||
|
tree.selected().map(TreeNodeId::path),
|
||||||
|
Some(Path::new("personal"))
|
||||||
|
);
|
||||||
|
assert_eq!(tree.navigate(NavigationKey::Expand), NavigationIntent::None);
|
||||||
|
assert_eq!(tree.rows().len(), 4);
|
||||||
|
assert_eq!(tree.navigate(NavigationKey::Expand), NavigationIntent::None);
|
||||||
|
assert_eq!(
|
||||||
|
tree.navigate(NavigationKey::Activate),
|
||||||
|
NavigationIntent::OpenEntry("personal/email".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tree.navigate(NavigationKey::Collapse),
|
||||||
|
NavigationIntent::None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tree.selected().map(TreeNodeId::path),
|
||||||
|
Some(Path::new("personal"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tree.activate(TreeNodeId::Directory(
|
||||||
|
DirectoryPath::parse("personal").expect("path")
|
||||||
|
)),
|
||||||
|
NavigationIntent::None
|
||||||
|
);
|
||||||
|
assert_eq!(tree.rows().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refresh_preserves_valid_typed_identity_and_clears_removed_state() {
|
||||||
|
let mut tree = NavigationTree::default();
|
||||||
|
tree.replace_test_nodes(populated());
|
||||||
|
assert!(tree.select_entry_path("personal/email"));
|
||||||
|
let selected = tree.selected().cloned();
|
||||||
|
tree.replace_test_nodes(populated());
|
||||||
|
assert_eq!(tree.selected(), selected.as_ref());
|
||||||
|
assert!(tree.rows().iter().any(|row| row.name.contains("very long")));
|
||||||
|
|
||||||
|
tree.replace_test_nodes(vec![directory("personal", Vec::new())]);
|
||||||
|
assert!(tree.selected().is_none());
|
||||||
|
assert_eq!(tree.expanded.len(), 1);
|
||||||
|
tree.replace_test_nodes(Vec::new());
|
||||||
|
assert!(tree.selected().is_none());
|
||||||
|
assert!(tree.expanded.is_empty());
|
||||||
|
assert!(tree.rows().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
tree.navigate(NavigationKey::Activate),
|
||||||
|
NavigationIntent::None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,12 +15,49 @@ use crate::{
|
|||||||
|
|
||||||
const EXTENSIONS_DIRECTORY: &str = ".extensions";
|
const EXTENSIONS_DIRECTORY: &str = ".extensions";
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
pub enum TreeNodeKind {
|
pub enum TreeNodeKind {
|
||||||
Directory,
|
Directory,
|
||||||
Entry,
|
Entry,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stable storage-owned identity for a navigation tree object.
|
||||||
|
///
|
||||||
|
/// Frontends retain this value across refreshed [`TreeModel`] instances
|
||||||
|
/// instead of reconstructing identity from rendered labels.
|
||||||
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub enum TreeNodeId {
|
||||||
|
Directory(DirectoryPath),
|
||||||
|
Entry(EntryPath),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TreeNodeId {
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
match self {
|
||||||
|
Self::Directory(path) => path.as_path(),
|
||||||
|
Self::Entry(path) => path.as_path(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind(&self) -> TreeNodeKind {
|
||||||
|
match self {
|
||||||
|
Self::Directory(_) => TreeNodeKind::Directory,
|
||||||
|
Self::Entry(_) => TreeNodeKind::Entry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_directory(&self) -> bool {
|
||||||
|
matches!(self, Self::Directory(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entry(&self) -> Option<&EntryPath> {
|
||||||
|
match self {
|
||||||
|
Self::Entry(path) => Some(path),
|
||||||
|
Self::Directory(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Presentation-safe state calculated by storage services for a tree object.
|
/// Presentation-safe state calculated by storage services for a tree object.
|
||||||
/// Frontends render these flags and never infer them from names or paths.
|
/// Frontends render these flags and never infer them from names or paths.
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
@@ -55,8 +92,7 @@ impl TreeNodeIndicators {
|
|||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct TreeNode {
|
pub struct TreeNode {
|
||||||
name: String,
|
name: String,
|
||||||
path: String,
|
id: TreeNodeId,
|
||||||
kind: TreeNodeKind,
|
|
||||||
indicators: TreeNodeIndicators,
|
indicators: TreeNodeIndicators,
|
||||||
children: Vec<TreeNode>,
|
children: Vec<TreeNode>,
|
||||||
}
|
}
|
||||||
@@ -67,11 +103,18 @@ impl TreeNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn path(&self) -> &str {
|
pub fn path(&self) -> &str {
|
||||||
&self.path
|
self.id
|
||||||
|
.path()
|
||||||
|
.to_str()
|
||||||
|
.expect("tree construction rejects non-UTF-8 paths")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn kind(&self) -> TreeNodeKind {
|
pub fn kind(&self) -> TreeNodeKind {
|
||||||
self.kind
|
self.id.kind()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> &TreeNodeId {
|
||||||
|
&self.id
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn indicators(&self) -> TreeNodeIndicators {
|
pub fn indicators(&self) -> TreeNodeIndicators {
|
||||||
@@ -630,8 +673,10 @@ fn finalize_children(node: MutableNode, parent: &Path) -> Result<Vec<TreeNode>,
|
|||||||
let children = finalize_children(child, &path)?;
|
let children = finalize_children(child, &path)?;
|
||||||
Ok(TreeNode {
|
Ok(TreeNode {
|
||||||
name,
|
name,
|
||||||
path: path_text(&path)?,
|
id: match kind {
|
||||||
kind,
|
TreeNodeKind::Directory => TreeNodeId::Directory(DirectoryPath::parse(&path)?),
|
||||||
|
TreeNodeKind::Entry => TreeNodeId::Entry(EntryPath::parse(&path)?),
|
||||||
|
},
|
||||||
indicators: TreeNodeIndicators::default(),
|
indicators: TreeNodeIndicators::default(),
|
||||||
children,
|
children,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user