feat: continue tree rust family cutover

- add rust renderer/state-family scaffolds and inline compat host thinning for page tree, file tree, and picker
- route tree/filetree preflight, file projection, resource artifact, and stream delta contracts through rust plans
- preserve canonical move-order validation, file-tree search projection, and related frontend/runtime regression coverage
This commit is contained in:
lix-2026
2026-04-26 19:35:52 +08:00
parent 338bb2e20f
commit e564dfde02
93 changed files with 17492 additions and 1856 deletions
@@ -0,0 +1,78 @@
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TreeShellAction {
Open,
CreateChild,
Rename,
Move,
ContextMenu,
Pick,
AssetOpen,
ResourceCopy,
ResourceMove,
ResourceUpload,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TreeShellActionRegistry {
pub actions: BTreeSet<TreeShellAction>,
}
impl TreeShellActionRegistry {
pub fn page_tree() -> Self {
Self {
actions: BTreeSet::from([
TreeShellAction::Open,
TreeShellAction::CreateChild,
TreeShellAction::Rename,
TreeShellAction::Move,
TreeShellAction::ContextMenu,
]),
}
}
pub fn file_tree() -> Self {
Self {
actions: BTreeSet::from([
TreeShellAction::Open,
TreeShellAction::ContextMenu,
TreeShellAction::AssetOpen,
TreeShellAction::ResourceCopy,
TreeShellAction::ResourceMove,
TreeShellAction::ResourceUpload,
]),
}
}
pub fn picker() -> Self {
Self {
actions: BTreeSet::from([TreeShellAction::Pick]),
}
}
pub fn allows(&self, action: TreeShellAction) -> bool {
self.actions.contains(&action)
}
}
#[cfg(test)]
mod tests {
use super::{TreeShellAction, TreeShellActionRegistry};
#[test]
fn tree_shell_action_registry_separates_page_filetree_and_picker_actions() {
let page = TreeShellActionRegistry::page_tree();
assert!(page.allows(TreeShellAction::CreateChild));
assert!(!page.allows(TreeShellAction::ResourceUpload));
let filetree = TreeShellActionRegistry::file_tree();
assert!(filetree.allows(TreeShellAction::ResourceMove));
assert!(filetree.allows(TreeShellAction::AssetOpen));
assert!(!filetree.allows(TreeShellAction::Rename));
let picker = TreeShellActionRegistry::picker();
assert!(picker.allows(TreeShellAction::Pick));
assert!(!picker.allows(TreeShellAction::Open));
}
}
@@ -0,0 +1,63 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TreeShellDragEffect {
Copy,
Move,
None,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeShellDragPayload {
pub row_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TreeShellDropTarget {
pub row_id: Option<String>,
pub document_id: Option<String>,
pub asset_id: Option<String>,
}
pub fn resolve_drag_effect(has_external_files: bool, alt_key: bool) -> TreeShellDragEffect {
if has_external_files || alt_key {
TreeShellDragEffect::Copy
} else {
TreeShellDragEffect::Move
}
}
pub fn normalize_drag_payload(row_ids: &[String]) -> Option<TreeShellDragPayload> {
let mut normalized = Vec::new();
for row_id in row_ids {
let row_id = row_id.trim();
if row_id.is_empty() || normalized.iter().any(|existing| existing == row_id) {
continue;
}
normalized.push(row_id.to_string());
}
if normalized.is_empty() {
None
} else {
Some(TreeShellDragPayload {
row_ids: normalized,
})
}
}
#[cfg(test)]
mod tests {
use super::{normalize_drag_payload, resolve_drag_effect, TreeShellDragEffect};
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn tree_shell_drag_drop_state_normalizes_payload_and_effect() {
let payload = normalize_drag_payload(&ids(&[" doc:a ", "asset:b", "doc:a", ""])).unwrap();
assert_eq!(payload.row_ids, ids(&["doc:a", "asset:b"]));
assert_eq!(resolve_drag_effect(true, false), TreeShellDragEffect::Copy);
assert_eq!(resolve_drag_effect(false, true), TreeShellDragEffect::Copy);
assert_eq!(resolve_drag_effect(false, false), TreeShellDragEffect::Move);
assert!(normalize_drag_payload(&ids(&["", " "])).is_none());
}
}
@@ -0,0 +1,66 @@
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TreeShellExpansionState {
pub expanded_ids: BTreeSet<String>,
}
impl TreeShellExpansionState {
pub fn from_defaults(default_expanded_ids: &[String]) -> Self {
Self {
expanded_ids: default_expanded_ids.iter().cloned().collect(),
}
}
pub fn toggle(&self, node_id: &str) -> Self {
let mut expanded_ids = self.expanded_ids.clone();
if !expanded_ids.insert(node_id.to_string()) {
expanded_ids.remove(node_id);
}
Self { expanded_ids }
}
pub fn expand_ancestors(
&self,
node_id: &str,
parent_by_id: &BTreeMap<String, Option<String>>,
) -> Self {
let mut expanded_ids = self.expanded_ids.clone();
let mut current = parent_by_id.get(node_id).and_then(Clone::clone);
while let Some(parent_id) = current {
expanded_ids.insert(parent_id.clone());
current = parent_by_id.get(&parent_id).and_then(Clone::clone);
}
Self { expanded_ids }
}
}
#[cfg(test)]
mod tests {
use super::TreeShellExpansionState;
use std::collections::{BTreeMap, BTreeSet};
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn tree_shell_expansion_state_toggles_and_expands_active_ancestors() {
let state = TreeShellExpansionState::from_defaults(&ids(&["doc:root"]));
assert!(state.expanded_ids.contains("doc:root"));
let state = state.toggle("doc:root");
assert!(!state.expanded_ids.contains("doc:root"));
let parent_by_id = BTreeMap::from([
("doc:root".into(), None),
("doc:child".into(), Some("doc:root".into())),
("doc:leaf".into(), Some("doc:child".into())),
]);
let state = state.expand_ancestors("doc:leaf", &parent_by_id);
assert_eq!(
state.expanded_ids,
BTreeSet::from(["doc:root".into(), "doc:child".into()])
);
}
}
@@ -1,10 +1,25 @@
use super::protocol;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileTreeRenderRow {
pub row_id: String,
pub row_kind: String,
pub node_id: String,
pub parent_node_id: Option<String>,
pub title: String,
pub depth: u32,
pub expandable: bool,
pub expanded: bool,
pub icon_kind: String,
pub document_id: Option<String>,
pub asset_id: Option<String>,
pub selected: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileTreeInitialRenderInput {
pub rows: Vec<FileTreeRenderRow>,
}
pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str, String)> {
@@ -19,3 +34,139 @@ pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str,
})
.collect()
}
fn escape_html(input: &str) -> String {
input
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn row_test_id(row_kind: &str) -> &'static str {
match row_kind {
"document" => protocol::TEST_ID_FILETREE_DOC_ROW,
"index" => protocol::TEST_ID_FILETREE_INDEX_ROW,
_ => protocol::TEST_ID_FILETREE_ASSET_ROW,
}
}
fn render_filetree_row(
html: &mut String,
row: &FileTreeRenderRow,
children_by_parent: &BTreeMap<Option<String>, Vec<FileTreeRenderRow>>,
) {
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}" data-document-id="{document_id}" data-asset-id="{asset_id}" data-shell-mode="filetree" data-selected="{selected}" data-active="false"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
node_id = escape_html(&row.node_id),
aria_level = row.depth + 1,
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
test_id = row_test_id(&row.row_kind),
row_id = escape_html(&row.row_id),
row_kind = escape_html(&row.row_kind),
document_id = escape_html(row.document_id.as_deref().unwrap_or_default()),
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
selected = row.selected,
icon_kind = escape_html(&row.icon_kind),
title = escape_html(&row.title),
));
if row.expandable && row.expanded {
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
html.push_str(r#"<ul class="tree-children">"#);
for child in children {
render_filetree_row(html, child, children_by_parent);
}
html.push_str("</ul>");
}
}
html.push_str("</li>");
}
pub fn render_initial_filetree_html(input: &FileTreeInitialRenderInput) -> String {
let mut html = String::from(
r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">"#,
);
if input.rows.is_empty() {
html.push_str(
r#"<li class="tree-empty" data-rust-rendered-row="filetree-empty">当前 file tree 没有可渲染的页面。</li>"#,
);
html.push_str("</ul>");
return html;
}
let ids = input
.rows
.iter()
.map(|row| row.node_id.clone())
.collect::<BTreeSet<_>>();
let mut children_by_parent = BTreeMap::<Option<String>, Vec<FileTreeRenderRow>>::new();
for row in &input.rows {
let parent_id = row
.parent_node_id
.as_ref()
.filter(|parent_id| ids.contains(*parent_id))
.cloned();
children_by_parent
.entry(parent_id)
.or_default()
.push(row.clone());
}
if let Some(roots) = children_by_parent.get(&None).cloned() {
for root in &roots {
render_filetree_row(&mut html, root, &children_by_parent);
}
}
html.push_str("</ul>");
html
}
#[cfg(test)]
mod tests {
use super::{render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow};
#[test]
fn tree_shell_filetree_renderer_outputs_initial_nested_html_contract() {
let html = render_initial_filetree_html(&FileTreeInitialRenderInput {
rows: vec![
FileTreeRenderRow {
row_id: "doc:page_root".into(),
row_kind: "document".into(),
node_id: "page_root".into(),
parent_node_id: None,
title: "首页 <安全>".into(),
depth: 0,
expandable: true,
expanded: true,
icon_kind: "page".into(),
document_id: Some("page_root".into()),
asset_id: None,
selected: true,
},
FileTreeRenderRow {
row_id: "index:page_root".into(),
row_kind: "index".into(),
node_id: "index:page_root".into(),
parent_node_id: Some("page_root".into()),
title: "index.md".into(),
depth: 1,
expandable: false,
expanded: false,
icon_kind: "index".into(),
document_id: Some("page_root".into()),
asset_id: None,
selected: false,
},
],
});
assert!(html.contains("data-rust-filetree-renderer=\"initial_v1\""));
assert!(html.contains("data-rust-rendered-row=\"filetree\""));
assert!(html.contains("data-testid=\"filetree-doc-row\""));
assert!(html.contains("data-testid=\"filetree-index-row\""));
assert!(html.contains("tree-children"));
assert!(html.contains("首页 &lt;安全&gt;"));
assert!(html.contains("data-selected=\"true\""));
}
}
@@ -0,0 +1,258 @@
use serde::Serialize;
use std::collections::BTreeSet;
pub const FILETREE_SELECTION_REDUCER_CONTRACT_NAME: &str = "rust_filetree_selection_reducer_v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeSelectionReducerContract {
pub contract_name: &'static str,
pub actions: BTreeSet<&'static str>,
}
impl Default for FileTreeSelectionReducerContract {
fn default() -> Self {
Self {
contract_name: FILETREE_SELECTION_REDUCER_CONTRACT_NAME,
actions: BTreeSet::from([
"select_row",
"select_context_row",
"normalize_visible_rows",
"clear",
"resolve_drag_rows",
]),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeSelectionModifiers {
pub shift_key: bool,
pub ctrl_key: bool,
pub meta_key: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeSelectionState {
pub selected_row_ids: BTreeSet<String>,
pub anchor_row_id: Option<String>,
pub focused_row_id: Option<String>,
}
impl FileTreeSelectionState {
pub fn from_selected(row_ids: &[String]) -> Self {
Self {
selected_row_ids: row_ids.iter().cloned().collect(),
anchor_row_id: row_ids.first().cloned(),
focused_row_id: row_ids.first().cloned(),
}
}
pub fn select_row(
&self,
row_id: &str,
visible_row_ids: &[String],
modifiers: FileTreeSelectionModifiers,
) -> Self {
let toggle_selection = modifiers.ctrl_key || modifiers.meta_key;
if modifiers.shift_key {
let anchor = self
.anchor_row_id
.clone()
.or_else(|| self.focused_row_id.clone())
.unwrap_or_else(|| row_id.to_string());
let mut selected_row_ids = if toggle_selection {
self.selected_row_ids.clone()
} else {
BTreeSet::new()
};
for range_row_id in range_row_ids(visible_row_ids, &anchor, row_id) {
selected_row_ids.insert(range_row_id);
}
return Self {
selected_row_ids,
anchor_row_id: self.anchor_row_id.clone().or(Some(anchor)),
focused_row_id: Some(row_id.to_string()),
};
}
if toggle_selection {
let mut selected_row_ids = self.selected_row_ids.clone();
if selected_row_ids.contains(row_id) {
selected_row_ids.remove(row_id);
} else {
selected_row_ids.insert(row_id.to_string());
}
return Self {
selected_row_ids,
anchor_row_id: Some(row_id.to_string()),
focused_row_id: Some(row_id.to_string()),
};
}
Self {
selected_row_ids: BTreeSet::from([row_id.to_string()]),
anchor_row_id: Some(row_id.to_string()),
focused_row_id: Some(row_id.to_string()),
}
}
pub fn select_context_row(&self, row_id: &str) -> Self {
if self.selected_row_ids.contains(row_id) {
return Self {
selected_row_ids: self.selected_row_ids.clone(),
anchor_row_id: self.anchor_row_id.clone(),
focused_row_id: Some(row_id.to_string()),
};
}
Self {
selected_row_ids: BTreeSet::from([row_id.to_string()]),
anchor_row_id: Some(row_id.to_string()),
focused_row_id: Some(row_id.to_string()),
}
}
pub fn normalize_for_visible_rows(&self, visible_row_ids: &[String]) -> Self {
let visible = visible_row_ids.iter().collect::<BTreeSet<_>>();
Self {
selected_row_ids: self
.selected_row_ids
.iter()
.filter(|row_id| visible.contains(row_id))
.cloned()
.collect(),
anchor_row_id: self
.anchor_row_id
.as_ref()
.filter(|row_id| visible.contains(row_id))
.cloned(),
focused_row_id: self
.focused_row_id
.as_ref()
.filter(|row_id| visible.contains(row_id))
.cloned(),
}
}
pub fn clear(&self) -> Self {
Self::default()
}
pub fn resolve_drag_row_ids(&self, row_id: &str) -> Vec<String> {
if self.selected_row_ids.contains(row_id) {
return self.selected_row_ids.iter().cloned().collect();
}
vec![row_id.to_string()]
}
}
fn range_row_ids(visible_row_ids: &[String], from_id: &str, to_id: &str) -> Vec<String> {
let from_index = visible_row_ids.iter().position(|row_id| row_id == from_id);
let to_index = visible_row_ids.iter().position(|row_id| row_id == to_id);
let (Some(from_index), Some(to_index)) = (from_index, to_index) else {
return vec![to_id.to_string()];
};
let low = from_index.min(to_index);
let high = from_index.max(to_index);
visible_row_ids[low..=high].to_vec()
}
#[cfg(test)]
mod tests {
use super::{
FileTreeSelectionModifiers, FileTreeSelectionReducerContract, FileTreeSelectionState,
FILETREE_SELECTION_REDUCER_CONTRACT_NAME,
};
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn tree_shell_filetree_selection_click_toggle_and_range_follow_contract() {
let visible = ids(&["doc:a", "doc:b", "asset:c", "asset:d"]);
let mut state = FileTreeSelectionState::default();
state = state.select_row(
"doc:b",
&visible,
FileTreeSelectionModifiers::default(),
);
assert_eq!(state.selected_row_ids, ids(&["doc:b"]).into_iter().collect());
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:b"));
assert_eq!(state.focused_row_id.as_deref(), Some("doc:b"));
state = state.select_row(
"asset:d",
&visible,
FileTreeSelectionModifiers {
shift_key: true,
..FileTreeSelectionModifiers::default()
},
);
assert_eq!(
state.selected_row_ids,
ids(&["doc:b", "asset:c", "asset:d"]).into_iter().collect()
);
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:b"));
assert_eq!(state.focused_row_id.as_deref(), Some("asset:d"));
state = state.select_row(
"doc:a",
&visible,
FileTreeSelectionModifiers {
ctrl_key: true,
..FileTreeSelectionModifiers::default()
},
);
assert_eq!(
state.selected_row_ids,
ids(&["doc:a", "doc:b", "asset:c", "asset:d"]).into_iter().collect()
);
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a"));
assert_eq!(state.focused_row_id.as_deref(), Some("doc:a"));
}
#[test]
fn tree_shell_filetree_selection_context_clear_normalize_and_drag_rows_are_stable() {
let visible = ids(&["doc:a", "doc:b", "asset:c"]);
let mut state = FileTreeSelectionState::from_selected(&ids(&["doc:a", "asset:missing"]));
state.focused_row_id = Some("asset:missing".into());
state = state.normalize_for_visible_rows(&visible);
assert_eq!(state.selected_row_ids, ids(&["doc:a"]).into_iter().collect());
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a"));
assert_eq!(state.focused_row_id, None);
state = state.select_context_row("asset:c");
assert_eq!(state.selected_row_ids, ids(&["asset:c"]).into_iter().collect());
assert_eq!(state.anchor_row_id.as_deref(), Some("asset:c"));
assert_eq!(state.focused_row_id.as_deref(), Some("asset:c"));
assert_eq!(state.resolve_drag_row_ids("asset:c"), ids(&["asset:c"]));
assert_eq!(state.resolve_drag_row_ids("doc:b"), ids(&["doc:b"]));
state = state.clear();
assert!(state.selected_row_ids.is_empty());
assert_eq!(state.anchor_row_id, None);
assert_eq!(state.focused_row_id, None);
}
#[test]
fn tree_shell_filetree_selection_reducer_contract_exposes_supported_actions() {
let contract = FileTreeSelectionReducerContract::default();
assert_eq!(
contract.contract_name,
FILETREE_SELECTION_REDUCER_CONTRACT_NAME
);
assert!(contract.actions.contains("select_row"));
assert!(contract.actions.contains("select_context_row"));
assert!(contract.actions.contains("normalize_visible_rows"));
assert!(contract.actions.contains("clear"));
assert!(contract.actions.contains("resolve_drag_rows"));
}
}
@@ -0,0 +1,145 @@
use serde::Serialize;
use std::collections::BTreeSet;
pub const PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME: &str =
"rust_page_focus_keyboard_reducer_v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageFocusKeyboardReducerContract {
pub contract_name: &'static str,
pub actions: BTreeSet<&'static str>,
}
impl Default for PageFocusKeyboardReducerContract {
fn default() -> Self {
Self {
contract_name: PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME,
actions: BTreeSet::from([
"normalize",
"focus",
"move_next",
"move_previous",
"move_home",
"move_end",
"expand",
"collapse",
"open",
"context_menu",
]),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TreeShellFocusState {
pub focused_id: Option<String>,
}
impl TreeShellFocusState {
pub fn normalize(&self, visible_ids: &[String]) -> Self {
if let Some(focused_id) = self.focused_id.as_deref() {
if visible_ids.iter().any(|id| id == focused_id) {
return self.clone();
}
}
Self {
focused_id: visible_ids.first().cloned(),
}
}
pub fn move_next(&self, visible_ids: &[String]) -> Self {
self.move_by(visible_ids, 1)
}
pub fn move_previous(&self, visible_ids: &[String]) -> Self {
self.move_by(visible_ids, -1)
}
pub fn move_home(&self, visible_ids: &[String]) -> Self {
Self {
focused_id: visible_ids.first().cloned(),
}
}
pub fn move_end(&self, visible_ids: &[String]) -> Self {
Self {
focused_id: visible_ids.last().cloned(),
}
}
fn move_by(&self, visible_ids: &[String], offset: isize) -> Self {
if visible_ids.is_empty() {
return Self::default();
}
let current_index = self
.focused_id
.as_deref()
.and_then(|focused_id| visible_ids.iter().position(|id| id == focused_id))
.unwrap_or(0);
let next_index = (current_index as isize + offset)
.clamp(0, (visible_ids.len() - 1) as isize) as usize;
Self {
focused_id: Some(visible_ids[next_index].clone()),
}
}
}
#[cfg(test)]
mod tests {
use super::{
PageFocusKeyboardReducerContract, TreeShellFocusState,
PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME,
};
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn tree_shell_focus_state_normalizes_and_moves_within_visible_rows() {
let visible_ids = ids(&["doc:a", "doc:b", "doc:c"]);
let state = TreeShellFocusState {
focused_id: Some("missing".into()),
}
.normalize(&visible_ids);
assert_eq!(state.focused_id.as_deref(), Some("doc:a"));
let state = state.move_next(&visible_ids).move_next(&visible_ids).move_next(&visible_ids);
assert_eq!(state.focused_id.as_deref(), Some("doc:c"));
assert_eq!(
state.move_previous(&visible_ids).focused_id.as_deref(),
Some("doc:b")
);
assert_eq!(state.move_home(&visible_ids).focused_id.as_deref(), Some("doc:a"));
assert_eq!(state.move_end(&visible_ids).focused_id.as_deref(), Some("doc:c"));
}
#[test]
fn tree_shell_focus_state_empty_rows_clear_focus() {
let state = TreeShellFocusState {
focused_id: Some("doc:a".into()),
}
.normalize(&[]);
assert_eq!(state.focused_id, None);
assert_eq!(state.move_next(&[]).focused_id, None);
}
#[test]
fn tree_shell_page_focus_keyboard_reducer_contract_exposes_supported_actions() {
let contract = PageFocusKeyboardReducerContract::default();
assert_eq!(
contract.contract_name,
PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME
);
assert!(contract.actions.contains("focus"));
assert!(contract.actions.contains("move_next"));
assert!(contract.actions.contains("move_previous"));
assert!(contract.actions.contains("move_home"));
assert!(contract.actions.contains("move_end"));
assert!(contract.actions.contains("expand"));
assert!(contract.actions.contains("collapse"));
assert!(contract.actions.contains("open"));
assert!(contract.actions.contains("context_menu"));
}
}
@@ -0,0 +1,80 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TreeShellKeyboardIntent {
MoveNext,
MovePrevious,
MoveHome,
MoveEnd,
Expand,
Collapse,
Open,
ContextMenu,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TreeShellKeyboardModifiers {
pub shift_key: bool,
pub ctrl_key: bool,
pub meta_key: bool,
}
pub fn resolve_tree_shell_keyboard_intent(
key: &str,
modifiers: TreeShellKeyboardModifiers,
) -> TreeShellKeyboardIntent {
match key {
"ArrowDown" => TreeShellKeyboardIntent::MoveNext,
"ArrowUp" => TreeShellKeyboardIntent::MovePrevious,
"Home" => TreeShellKeyboardIntent::MoveHome,
"End" => TreeShellKeyboardIntent::MoveEnd,
"ArrowRight" => TreeShellKeyboardIntent::Expand,
"ArrowLeft" => TreeShellKeyboardIntent::Collapse,
"Enter" => TreeShellKeyboardIntent::Open,
"ContextMenu" => TreeShellKeyboardIntent::ContextMenu,
"F10" if modifiers.shift_key && !modifiers.ctrl_key && !modifiers.meta_key => {
TreeShellKeyboardIntent::ContextMenu
}
_ => TreeShellKeyboardIntent::None,
}
}
#[cfg(test)]
mod tests {
use super::{
resolve_tree_shell_keyboard_intent, TreeShellKeyboardIntent, TreeShellKeyboardModifiers,
};
#[test]
fn tree_shell_keyboard_state_maps_navigation_open_and_context_menu_intents() {
assert_eq!(
resolve_tree_shell_keyboard_intent("ArrowDown", TreeShellKeyboardModifiers::default()),
TreeShellKeyboardIntent::MoveNext
);
assert_eq!(
resolve_tree_shell_keyboard_intent("ArrowUp", TreeShellKeyboardModifiers::default()),
TreeShellKeyboardIntent::MovePrevious
);
assert_eq!(
resolve_tree_shell_keyboard_intent("Home", TreeShellKeyboardModifiers::default()),
TreeShellKeyboardIntent::MoveHome
);
assert_eq!(
resolve_tree_shell_keyboard_intent("End", TreeShellKeyboardModifiers::default()),
TreeShellKeyboardIntent::MoveEnd
);
assert_eq!(
resolve_tree_shell_keyboard_intent("Enter", TreeShellKeyboardModifiers::default()),
TreeShellKeyboardIntent::Open
);
assert_eq!(
resolve_tree_shell_keyboard_intent(
"F10",
TreeShellKeyboardModifiers {
shift_key: true,
..TreeShellKeyboardModifiers::default()
},
),
TreeShellKeyboardIntent::ContextMenu
);
}
}
@@ -1,9 +1,17 @@
pub mod action_registry;
pub mod drag_drop_state;
pub mod dispatcher;
pub mod expansion_state;
pub mod filetree_renderer;
pub mod filetree_selection;
pub mod focus_state;
pub mod keyboard_state;
pub mod loader;
pub mod page_renderer;
pub mod picker_renderer;
pub mod picker_state;
pub mod protocol;
pub mod renderer_input;
pub mod state;
use leptos::prelude::*;
@@ -1,11 +1,14 @@
use super::protocol;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageTreeRenderRow {
pub node_id: String,
pub parent_node_id: Option<String>,
pub title: String,
pub depth: u32,
pub expandable: bool,
pub expanded: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -20,6 +23,13 @@ pub struct PageTreeDomRow {
pub expandable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageTreeInitialRenderInput {
pub rows: Vec<PageTreeRenderRow>,
pub active_node_id: Option<String>,
pub focused_node_id: Option<String>,
}
pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec<PageTreeDomRow> {
rows.iter()
.map(|row| PageTreeDomRow {
@@ -35,24 +45,127 @@ pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec<PageTreeDomRo
.collect()
}
fn escape_html(input: &str) -> String {
input
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn render_page_row(
html: &mut String,
row: &PageTreeRenderRow,
children_by_parent: &BTreeMap<Option<String>, Vec<PageTreeRenderRow>>,
input: &PageTreeInitialRenderInput,
) {
let dom_rows = build_page_tree_dom_rows(&[row.clone()]);
if let Some(row) = dom_rows.first() {
let active = input
.active_node_id
.as_deref()
.map(|active_node_id| active_node_id == row.node_id)
.unwrap_or(false);
let focused = input
.focused_node_id
.as_deref()
.map(|focused_node_id| focused_node_id == row.node_id)
.unwrap_or(false);
let expanded = input
.rows
.iter()
.find(|source| source.node_id == row.node_id)
.map(|source| source.expanded)
.unwrap_or(false);
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="{test_id}" data-node-id="{node_id}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button></div></div>"#,
node_id = escape_html(&row.node_id),
aria_level = row.depth + 1,
expanded_attr = if row.expandable && expanded { "true" } else { "false" },
test_id = row.test_id,
active = active,
focused = focused,
tab_index = if focused { "0" } else { "-1" },
title = escape_html(&row.title),
));
if row.expandable && expanded {
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
html.push_str(r#"<ul class="tree-children">"#);
for child in children {
render_page_row(html, child, children_by_parent, input);
}
html.push_str("</ul>");
}
}
html.push_str("</li>");
}
}
pub fn render_initial_page_tree_html(input: &PageTreeInitialRenderInput) -> String {
let mut html = String::from(
r#"<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">"#,
);
if input.rows.is_empty() {
html.push_str(
r#"<li class="tree-empty" data-rust-rendered-row="page-empty">当前 projection 没有可渲染的页面。</li>"#,
);
html.push_str("</ul>");
return html;
}
let ids = input
.rows
.iter()
.map(|row| row.node_id.clone())
.collect::<std::collections::BTreeSet<_>>();
let mut children_by_parent = BTreeMap::<Option<String>, Vec<PageTreeRenderRow>>::new();
for row in &input.rows {
let parent_id = row
.parent_node_id
.as_ref()
.filter(|parent_id| ids.contains(*parent_id))
.cloned();
children_by_parent
.entry(parent_id)
.or_default()
.push(row.clone());
}
if let Some(roots) = children_by_parent.get(&None).cloned() {
for root in &roots {
render_page_row(&mut html, root, &children_by_parent, input);
}
}
html.push_str("</ul>");
html
}
#[cfg(test)]
mod tests {
use super::{build_page_tree_dom_rows, PageTreeRenderRow};
use super::{
build_page_tree_dom_rows, render_initial_page_tree_html, PageTreeInitialRenderInput,
PageTreeRenderRow,
};
#[test]
fn tree_shell_page_renderer_builds_rows_with_stable_testids() {
let rows = build_page_tree_dom_rows(&[
PageTreeRenderRow {
node_id: "page_root".into(),
parent_node_id: None,
title: "首页".into(),
depth: 0,
expandable: true,
expanded: false,
},
PageTreeRenderRow {
node_id: "page_child".into(),
parent_node_id: Some("page_root".into()),
title: "子页".into(),
depth: 1,
expandable: false,
expanded: false,
},
]);
@@ -63,4 +176,42 @@ mod tests {
assert_eq!(rows[0].action_move_up_test_id, "tree-action-move-up");
assert_eq!(rows[1].depth, 1);
}
#[test]
fn tree_shell_page_renderer_outputs_initial_html_contract() {
let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows: vec![
PageTreeRenderRow {
node_id: "page_root".into(),
parent_node_id: None,
title: "首页 <安全>".into(),
depth: 0,
expandable: true,
expanded: true,
},
PageTreeRenderRow {
node_id: "page_child".into(),
parent_node_id: Some("page_root".into()),
title: "子页".into(),
depth: 1,
expandable: false,
expanded: false,
},
],
active_node_id: Some("page_root".into()),
focused_node_id: Some("page_root".into()),
});
assert!(html.contains("data-rust-page-renderer=\"initial_v1\""));
assert!(html.contains("data-rust-rendered-row=\"page\""));
assert!(html.contains("data-shell-mode=\"page\""));
assert!(html.contains("data-rust-action=\"open\""));
assert!(html.contains("data-rust-action=\"create\""));
assert!(html.contains("draggable=\"true\""));
assert!(html.contains("tree-children"));
assert!(html.contains("子页"));
assert!(html.contains("首页 &lt;安全&gt;"));
assert!(html.contains("data-active=\"true\""));
assert!(html.contains("data-focused=\"true\""));
}
}
@@ -1,9 +1,15 @@
use super::protocol;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerRenderRow {
pub node_id: String,
pub parent_node_id: Option<String>,
pub title: String,
pub depth: u32,
pub expandable: bool,
pub expanded: bool,
pub active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -13,6 +19,13 @@ pub struct PickerRenderResult {
pub allow_root_pick: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerInitialRenderInput {
pub rows: Vec<PickerRenderRow>,
pub allow_root_pick: bool,
pub root_active: bool,
}
pub fn build_picker_render_result(
rows: &[PickerRenderRow],
allow_root_pick: bool,
@@ -24,10 +37,84 @@ pub fn build_picker_render_result(
}
}
fn escape_html(input: &str) -> String {
input
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn render_picker_row(
html: &mut String,
row: &PickerRenderRow,
children_by_parent: &BTreeMap<Option<String>, Vec<PickerRenderRow>>,
) {
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
node_id = escape_html(&row.node_id),
aria_level = row.depth + 1,
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
active = row.active,
title = escape_html(&row.title),
));
if row.expandable && row.expanded {
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
html.push_str(r#"<ul class="tree-children">"#);
for child in children {
render_picker_row(html, child, children_by_parent);
}
html.push_str("</ul>");
}
}
html.push_str("</li>");
}
pub fn render_initial_picker_html(input: &PickerInitialRenderInput) -> String {
let mut html = String::from(
r#"<ul class="tree-root" role="tree" data-rust-picker-renderer="initial_v1">"#,
);
if input.allow_root_pick {
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="{focused}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>"#,
focused = input.root_active,
));
}
let ids = input
.rows
.iter()
.map(|row| row.node_id.clone())
.collect::<BTreeSet<_>>();
let mut children_by_parent = BTreeMap::<Option<String>, Vec<PickerRenderRow>>::new();
for row in &input.rows {
let parent_id = row
.parent_node_id
.as_ref()
.filter(|parent_id| ids.contains(*parent_id))
.cloned();
children_by_parent
.entry(parent_id)
.or_default()
.push(row.clone());
}
if let Some(roots) = children_by_parent.get(&None).cloned() {
for root in &roots {
render_picker_row(&mut html, root, &children_by_parent);
}
}
html.push_str("</ul>");
html
}
#[cfg(test)]
mod tests {
use super::super::filetree_renderer::{build_filetree_testids, FileTreeRenderRow};
use super::{build_picker_render_result, PickerRenderRow};
use super::{
build_picker_render_result, render_initial_picker_html, PickerInitialRenderInput,
PickerRenderRow,
};
#[test]
fn tree_shell_filetree_picker_builds_file_rows_and_picker_mode() {
@@ -35,18 +122,41 @@ mod tests {
FileTreeRenderRow {
row_id: "doc:page_root".into(),
row_kind: "document".into(),
node_id: "page_root".into(),
parent_node_id: None,
title: "首页".into(),
depth: 0,
expandable: false,
expanded: false,
icon_kind: "page".into(),
document_id: Some("page_root".into()),
asset_id: None,
selected: false,
},
FileTreeRenderRow {
row_id: "asset:asset_1".into(),
row_kind: "asset".into(),
node_id: "asset:asset_1".into(),
parent_node_id: Some("page_root".into()),
title: "附件".into(),
depth: 1,
expandable: false,
expanded: false,
icon_kind: "file".into(),
document_id: Some("page_root".into()),
asset_id: Some("asset_1".into()),
selected: false,
},
]);
let picker = build_picker_render_result(
&[PickerRenderRow {
node_id: "page_root".into(),
parent_node_id: None,
title: "首页".into(),
depth: 0,
expandable: false,
expanded: false,
active: false,
}],
true,
);
@@ -56,4 +166,29 @@ mod tests {
assert_eq!(picker.root_test_id, "tree-picker-root");
assert!(picker.allow_root_pick);
}
#[test]
fn tree_shell_picker_renderer_outputs_initial_html_contract() {
let html = render_initial_picker_html(&PickerInitialRenderInput {
allow_root_pick: true,
root_active: false,
rows: vec![PickerRenderRow {
node_id: "page_root".into(),
parent_node_id: None,
title: "首页 <安全>".into(),
depth: 0,
expandable: false,
expanded: false,
active: true,
}],
});
assert!(html.contains("data-rust-picker-renderer=\"initial_v1\""));
assert!(html.contains("data-rust-rendered-row=\"picker-root\""));
assert!(html.contains("data-rust-rendered-row=\"picker\""));
assert!(html.contains("data-testid=\"tree-picker-root\""));
assert!(html.contains("data-testid=\"tree-picker-row\""));
assert!(html.contains("首页 &lt;安全&gt;"));
assert!(html.contains("data-focused=\"true\""));
}
}
@@ -0,0 +1,201 @@
use serde::Serialize;
use std::collections::BTreeSet;
pub const PICKER_STATE_REDUCER_CONTRACT_NAME: &str = "rust_picker_state_reducer_v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PickerStateReducerContract {
pub contract_name: &'static str,
pub actions: BTreeSet<&'static str>,
}
impl Default for PickerStateReducerContract {
fn default() -> Self {
Self {
contract_name: PICKER_STATE_REDUCER_CONTRACT_NAME,
actions: BTreeSet::from([
"normalize",
"focus",
"next",
"previous",
"home",
"end",
"pick",
]),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerItem {
pub item_key: String,
pub document_id: Option<String>,
pub pickable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PickerState {
pub active_item_key: Option<String>,
}
impl PickerState {
pub fn normalize(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
if let Some(active_item_key) = self.active_item_key.as_deref() {
if is_pickable_item_key(items, excluded_ids, active_item_key) {
return self.clone();
}
}
Self {
active_item_key: first_pickable_item_key(items, excluded_ids),
}
}
pub fn move_next(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
self.move_by(items, excluded_ids, 1)
}
pub fn move_previous(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
self.move_by(items, excluded_ids, -1)
}
pub fn move_home(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
Self {
active_item_key: first_pickable_item_key(items, excluded_ids),
}
}
pub fn move_end(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
Self {
active_item_key: pickable_items(items, excluded_ids).last().map(|item| item.item_key.clone()),
}
}
pub fn pick(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Option<String> {
let active = self.active_item_key.as_deref()?;
pickable_items(items, excluded_ids)
.find(|item| item.item_key == active)
.and_then(|item| item.document_id.clone())
}
fn move_by(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>, offset: isize) -> Self {
let pickable = pickable_items(items, excluded_ids).collect::<Vec<_>>();
if pickable.is_empty() {
return Self::default();
}
let current_index = self
.active_item_key
.as_deref()
.and_then(|active| pickable.iter().position(|item| item.item_key == active))
.unwrap_or(0);
let next_index = (current_index as isize + offset)
.clamp(0, (pickable.len() - 1) as isize) as usize;
Self {
active_item_key: Some(pickable[next_index].item_key.clone()),
}
}
}
fn is_item_excluded(item: &PickerItem, excluded_ids: &BTreeSet<String>) -> bool {
item.document_id
.as_ref()
.is_some_and(|document_id| excluded_ids.contains(document_id))
}
fn pickable_items<'a>(
items: &'a [PickerItem],
excluded_ids: &'a BTreeSet<String>,
) -> impl DoubleEndedIterator<Item = &'a PickerItem> + 'a {
items
.iter()
.filter(move |item| item.pickable && !is_item_excluded(item, excluded_ids))
}
fn first_pickable_item_key(items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Option<String> {
pickable_items(items, excluded_ids).next().map(|item| item.item_key.clone())
}
fn is_pickable_item_key(
items: &[PickerItem],
excluded_ids: &BTreeSet<String>,
item_key: &str,
) -> bool {
pickable_items(items, excluded_ids).any(|item| item.item_key == item_key)
}
#[cfg(test)]
mod tests {
use super::{
PickerItem, PickerState, PickerStateReducerContract, PICKER_STATE_REDUCER_CONTRACT_NAME,
};
use std::collections::BTreeSet;
fn excluded(values: &[&str]) -> BTreeSet<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn item(item_key: &str, document_id: Option<&str>, pickable: bool) -> PickerItem {
PickerItem {
item_key: item_key.into(),
document_id: document_id.map(ToOwned::to_owned),
pickable,
}
}
#[test]
fn tree_shell_picker_state_skips_excluded_items_and_picks_active_document() {
let items = vec![
item("root", None, true),
item("doc:a", Some("doc:a"), true),
item("doc:b", Some("doc:b"), true),
item("doc:c", Some("doc:c"), true),
];
let excluded_ids = excluded(&["doc:b"]);
let state = PickerState {
active_item_key: Some("doc:b".into()),
}
.normalize(&items, &excluded_ids);
assert_eq!(state.active_item_key.as_deref(), Some("root"));
let state = state.move_next(&items, &excluded_ids).move_next(&items, &excluded_ids);
assert_eq!(state.active_item_key.as_deref(), Some("doc:c"));
assert_eq!(state.pick(&items, &excluded_ids).as_deref(), Some("doc:c"));
let state = state.move_previous(&items, &excluded_ids);
assert_eq!(state.active_item_key.as_deref(), Some("doc:a"));
}
#[test]
fn tree_shell_picker_state_home_end_and_empty_cases_are_stable() {
let items = vec![
item("doc:a", Some("doc:a"), true),
item("doc:b", Some("doc:b"), false),
item("doc:c", Some("doc:c"), true),
];
let excluded_ids = excluded(&[]);
let state = PickerState::default().move_end(&items, &excluded_ids);
assert_eq!(state.active_item_key.as_deref(), Some("doc:c"));
assert_eq!(
state.move_home(&items, &excluded_ids).active_item_key.as_deref(),
Some("doc:a")
);
let empty = PickerState::default().normalize(&items, &excluded(&["doc:a", "doc:c"]));
assert_eq!(empty.active_item_key, None);
assert_eq!(empty.pick(&items, &excluded(&["doc:a", "doc:c"])), None);
}
#[test]
fn tree_shell_picker_state_reducer_contract_exposes_supported_actions() {
let contract = PickerStateReducerContract::default();
assert_eq!(contract.contract_name, PICKER_STATE_REDUCER_CONTRACT_NAME);
assert!(contract.actions.contains("normalize"));
assert!(contract.actions.contains("focus"));
assert!(contract.actions.contains("next"));
assert!(contract.actions.contains("previous"));
assert!(contract.actions.contains("home"));
assert!(contract.actions.contains("end"));
assert!(contract.actions.contains("pick"));
}
}
@@ -0,0 +1,191 @@
use super::filetree_selection::{FileTreeSelectionReducerContract, FileTreeSelectionState};
use super::focus_state::PageFocusKeyboardReducerContract;
use super::picker_state::PickerStateReducerContract;
use serde::Serialize;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum TreeShellRendererMode {
Page,
FileTree,
Picker,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeShellRendererInput {
pub mode: TreeShellRendererMode,
pub projection_item_ids: Vec<String>,
pub expanded_ids: BTreeSet<String>,
pub focused_id: Option<String>,
pub page_focus_keyboard_reducer: Option<PageFocusKeyboardReducerContract>,
pub filetree_selection: FileTreeSelectionState,
pub filetree_selection_reducer: Option<FileTreeSelectionReducerContract>,
pub active_picker_item: Option<String>,
pub excluded_picker_ids: BTreeSet<String>,
pub picker_state_reducer: Option<PickerStateReducerContract>,
pub command_dispatcher: TreeShellCommandDispatcher,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeShellCommandDispatcher {
pub channel: String,
pub command_names: BTreeSet<String>,
}
impl TreeShellRendererInput {
pub fn page(input: PageTreeRendererInput) -> Self {
Self {
mode: TreeShellRendererMode::Page,
projection_item_ids: input.projection_item_ids,
expanded_ids: input.expanded_ids,
focused_id: input.focused_id,
page_focus_keyboard_reducer: Some(PageFocusKeyboardReducerContract::default()),
filetree_selection: FileTreeSelectionState::default(),
filetree_selection_reducer: None,
active_picker_item: None,
excluded_picker_ids: BTreeSet::new(),
picker_state_reducer: None,
command_dispatcher: input.command_dispatcher,
}
}
pub fn filetree(input: FileTreeRendererInput) -> Self {
Self {
mode: TreeShellRendererMode::FileTree,
projection_item_ids: input.projection_item_ids,
expanded_ids: input.expanded_ids,
focused_id: input.filetree_selection.focused_row_id.clone(),
page_focus_keyboard_reducer: None,
filetree_selection: input.filetree_selection,
filetree_selection_reducer: Some(FileTreeSelectionReducerContract::default()),
active_picker_item: None,
excluded_picker_ids: BTreeSet::new(),
picker_state_reducer: None,
command_dispatcher: input.command_dispatcher,
}
}
pub fn picker(input: PickerRendererInput) -> Self {
Self {
mode: TreeShellRendererMode::Picker,
projection_item_ids: input.projection_item_ids,
expanded_ids: input.expanded_ids,
focused_id: input.active_picker_item.clone(),
page_focus_keyboard_reducer: None,
filetree_selection: FileTreeSelectionState::default(),
filetree_selection_reducer: None,
active_picker_item: input.active_picker_item,
excluded_picker_ids: input.excluded_picker_ids,
picker_state_reducer: Some(PickerStateReducerContract::default()),
command_dispatcher: input.command_dispatcher,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageTreeRendererInput {
pub projection_item_ids: Vec<String>,
pub expanded_ids: BTreeSet<String>,
pub focused_id: Option<String>,
pub command_dispatcher: TreeShellCommandDispatcher,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileTreeRendererInput {
pub projection_item_ids: Vec<String>,
pub expanded_ids: BTreeSet<String>,
pub filetree_selection: FileTreeSelectionState,
pub command_dispatcher: TreeShellCommandDispatcher,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerRendererInput {
pub projection_item_ids: Vec<String>,
pub expanded_ids: BTreeSet<String>,
pub active_picker_item: Option<String>,
pub excluded_picker_ids: BTreeSet<String>,
pub command_dispatcher: TreeShellCommandDispatcher,
}
#[cfg(test)]
mod tests {
use super::{
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
TreeShellRendererInput, TreeShellRendererMode,
};
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
use std::collections::BTreeSet;
fn set(values: &[&str]) -> BTreeSet<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn dispatcher() -> TreeShellCommandDispatcher {
TreeShellCommandDispatcher {
channel: "mnote.tree.shell".into(),
command_names: set(&["tree.node.create", "tree.resource.move"]),
}
}
#[test]
fn tree_shell_renderer_input_contract_covers_page_filetree_and_picker_state() {
let page = TreeShellRendererInput::page(PageTreeRendererInput {
projection_item_ids: vec!["doc:root".into()],
expanded_ids: set(&["doc:root"]),
focused_id: Some("doc:root".into()),
command_dispatcher: dispatcher(),
});
assert_eq!(page.mode, TreeShellRendererMode::Page);
assert_eq!(page.focused_id.as_deref(), Some("doc:root"));
assert_eq!(page.command_dispatcher.channel, "mnote.tree.shell");
assert_eq!(
page.page_focus_keyboard_reducer
.as_ref()
.map(|contract| contract.contract_name),
Some("rust_page_focus_keyboard_reducer_v1")
);
let mut selection = FileTreeSelectionState::from_selected(&["asset:a".into()]);
selection.focused_row_id = Some("asset:a".into());
let filetree = TreeShellRendererInput::filetree(FileTreeRendererInput {
projection_item_ids: vec!["doc:root".into(), "asset:a".into()],
expanded_ids: set(&["doc:root"]),
filetree_selection: selection,
command_dispatcher: dispatcher(),
});
assert_eq!(filetree.mode, TreeShellRendererMode::FileTree);
assert_eq!(filetree.focused_id.as_deref(), Some("asset:a"));
assert!(filetree.filetree_selection.selected_row_ids.contains("asset:a"));
assert!(filetree.page_focus_keyboard_reducer.is_none());
assert_eq!(
filetree
.filetree_selection_reducer
.as_ref()
.map(|contract| contract.contract_name),
Some("rust_filetree_selection_reducer_v1")
);
let picker = TreeShellRendererInput::picker(PickerRendererInput {
projection_item_ids: vec!["doc:root".into(), "doc:child".into()],
expanded_ids: set(&["doc:root"]),
active_picker_item: Some("doc:child".into()),
excluded_picker_ids: set(&["doc:archived"]),
command_dispatcher: dispatcher(),
});
assert_eq!(picker.mode, TreeShellRendererMode::Picker);
assert_eq!(picker.focused_id.as_deref(), Some("doc:child"));
assert!(picker.excluded_picker_ids.contains("doc:archived"));
assert!(picker.page_focus_keyboard_reducer.is_none());
assert!(picker.filetree_selection_reducer.is_none());
assert_eq!(
picker
.picker_state_reducer
.as_ref()
.map(|contract| contract.contract_name),
Some("rust_picker_state_reducer_v1")
);
}
}