Implement sidebar tree view state persistence
This commit is contained in:
@@ -4,7 +4,8 @@ use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::{
|
||||
control_plane_db_path_display, create_default_local_workspace_for_actor,
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
load_local_folder_page_tree_snapshot, load_local_trash_entries,
|
||||
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
|
||||
load_local_trash_entries,
|
||||
};
|
||||
use crate::routes::snapshot_support::load_sidebar_dataset;
|
||||
use crate::routes::web_shell::{
|
||||
@@ -326,7 +327,11 @@ pub async fn root_entry(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
|
||||
let page_tree_snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
||||
let page_tree_snapshot = if let Some(scope) = file_tree_scope {
|
||||
load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
|
||||
} else {
|
||||
load_local_folder_page_tree_snapshot(root_uri)?
|
||||
};
|
||||
let workspace_id = page_tree_snapshot
|
||||
.dataset
|
||||
.get("workspace")
|
||||
@@ -2860,6 +2865,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_local_folder_filetree_scope_renders_scoped_page_tree() {
|
||||
let root = temp_root("mnote-root-local-folder-scoped-page-tree");
|
||||
std::fs::create_dir_all(root.join("design").join("05-editor-mainline"))
|
||||
.expect("create scoped design tree");
|
||||
std::fs::write(root.join("Home.md"), "# Home\n").expect("write root page");
|
||||
std::fs::write(
|
||||
root.join("design")
|
||||
.join("05-editor-mainline")
|
||||
.join("Target.md"),
|
||||
"# Target\n",
|
||||
)
|
||||
.expect("write scoped page");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-node-id="local-dir:design~2F05-editor-mainline""#));
|
||||
assert!(html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#));
|
||||
assert!(
|
||||
!html.contains(r#"data-node-id="local-md:Home.md""#),
|
||||
"星标 scoped folder 入口的 PageTree 不应回退到 workspace root 页面树"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_local_folder_without_active_page_keeps_resource_tab_host() {
|
||||
let root = temp_root("mnote-root-local-folder-no-active-page");
|
||||
|
||||
@@ -4330,8 +4330,8 @@ fn add_local_tree_command_affected_parents(result: &Value, action: &str) -> Vec<
|
||||
"old-parent",
|
||||
);
|
||||
}
|
||||
if let Some(parent_path) = local_tree_result_string(result, "parentRelativePath") {
|
||||
push_local_tree_affected_parent(&mut parents, &mut seen, parent_path, "parent");
|
||||
if let Some(parent_path) = result.get("parentRelativePath").and_then(Value::as_str) {
|
||||
push_local_tree_affected_parent(&mut parents, &mut seen, parent_path.to_string(), "parent");
|
||||
}
|
||||
if normalized_action != "delete"
|
||||
&& normalized_action != "trash"
|
||||
@@ -4607,6 +4607,7 @@ fn create_local_markdown_page(
|
||||
let stem = determine_page_stem(&parent_directory, title);
|
||||
let dir_target = parent_directory.join(&stem);
|
||||
let target = dir_target.join(format!("{}.md", stem));
|
||||
let parent_relative_path = normalize_relative_path(root, &parent_directory)?;
|
||||
let relative_path = normalize_relative_path(root, &target)?;
|
||||
let page_id = local_markdown_path_page_id(&relative_path);
|
||||
let display_title = file_stem_title(&format!("{stem}.md"));
|
||||
@@ -4628,6 +4629,7 @@ fn create_local_markdown_page(
|
||||
"id": page_id,
|
||||
"documentId": page_id,
|
||||
"title": display_title,
|
||||
"parentRelativePath": parent_relative_path,
|
||||
"relativePath": relative_path,
|
||||
"action": "create",
|
||||
"sourceKind": "local_folder",
|
||||
@@ -4642,6 +4644,7 @@ fn create_local_folder(
|
||||
let metadata = load_local_folder_metadata(root)?;
|
||||
let parent_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
|
||||
let target = parent_directory.join(determine_folder_stem(&parent_directory, title));
|
||||
let parent_relative_path = normalize_relative_path(root, &parent_directory)?;
|
||||
fs::create_dir_all(&target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_tree_command_failed",
|
||||
@@ -4651,6 +4654,7 @@ fn create_local_folder(
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"id": local_directory_group_id(&normalize_relative_path(root, &target)?),
|
||||
"parentRelativePath": parent_relative_path,
|
||||
"relativePath": normalize_relative_path(root, &target)?,
|
||||
"action": "createFolder",
|
||||
"sourceKind": "local_folder",
|
||||
@@ -12620,6 +12624,16 @@ fn main() {}
|
||||
);
|
||||
assert_eq!(result["action"], "create");
|
||||
assert_eq!(result["sourceKind"], "local_folder");
|
||||
assert_eq!(result["parentRelativePath"], "");
|
||||
assert!(
|
||||
result["affectedParents"]
|
||||
.as_array()
|
||||
.expect("affected parents")
|
||||
.iter()
|
||||
.any(|parent| parent["relativePath"].as_str() == Some("")),
|
||||
"create page affectedParents 应包含承载新页面包的父级 root: {}",
|
||||
result["affectedParents"]
|
||||
);
|
||||
|
||||
// 页面标题来自文件名,默认正文不再写第二个标题真相。
|
||||
let content = std::fs::read_to_string(root.join(relative_path)).expect("read markdown");
|
||||
@@ -12661,6 +12675,16 @@ fn main() {}
|
||||
.exists(),
|
||||
"default folder creation must not create page markdown"
|
||||
);
|
||||
assert_eq!(result["parentRelativePath"], "");
|
||||
assert!(
|
||||
result["affectedParents"]
|
||||
.as_array()
|
||||
.expect("affected parents")
|
||||
.iter()
|
||||
.any(|parent| parent["relativePath"].as_str() == Some("")),
|
||||
"create folder affectedParents 应包含承载新文件夹的父级 root: {}",
|
||||
result["affectedParents"]
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ mod snapshot_support;
|
||||
mod sse;
|
||||
mod stream_support;
|
||||
mod tree;
|
||||
mod tree_view_state;
|
||||
pub(crate) mod ui_preferences;
|
||||
pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
@@ -92,6 +93,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/ui/preferences",
|
||||
put(ui_preferences::update_preferences),
|
||||
)
|
||||
.route(
|
||||
"/api/tree/view-state",
|
||||
get(tree_view_state::get_tree_view_state).put(tree_view_state::put_tree_view_state),
|
||||
)
|
||||
.route(
|
||||
"/api/leptos-tiptap-runtime/manifest.json",
|
||||
get(web_shell::leptos_tiptap_manifest),
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::gateway::current_actor_id;
|
||||
use crate::routes::local_folder_source::local_workspace_id_from_root_uri;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const SIDEBAR_TREE_SCOPE_KIND: &str = "sidebar_tree";
|
||||
const SIDEBAR_TREE_VIEW_STATE_KEY: &str = "sidebarTreeViewState.v1";
|
||||
const MAX_STATE_ITEMS: usize = 512;
|
||||
const MAX_ID_LENGTH: usize = 2048;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct TreeViewStateQuery {
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: String,
|
||||
#[serde(default, alias = "source_kind")]
|
||||
source_kind: String,
|
||||
#[serde(default, alias = "tree_kind")]
|
||||
tree_kind: String,
|
||||
#[serde(default, alias = "root_uri")]
|
||||
root_uri: String,
|
||||
#[serde(default)]
|
||||
scope: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct TreeViewStateUpdateRequest {
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: String,
|
||||
#[serde(default, alias = "source_kind")]
|
||||
source_kind: String,
|
||||
#[serde(default, alias = "tree_kind")]
|
||||
tree_kind: String,
|
||||
#[serde(default, alias = "root_uri")]
|
||||
root_uri: String,
|
||||
#[serde(default)]
|
||||
scope: String,
|
||||
#[serde(default)]
|
||||
state: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct TreeViewStateScope {
|
||||
workspace_id: String,
|
||||
source_kind: String,
|
||||
tree_kind: String,
|
||||
root_uri: String,
|
||||
scope: String,
|
||||
scope_id: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn get_tree_view_state(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<TreeViewStateQuery>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let scope = resolve_scope(
|
||||
&context,
|
||||
&query.workspace_id,
|
||||
&query.source_kind,
|
||||
&query.tree_kind,
|
||||
&query.root_uri,
|
||||
&query.scope,
|
||||
)?;
|
||||
let preferences = state
|
||||
.control_plane()
|
||||
.list_user_ui_preferences(&actor_id, Some(&scope.workspace_id), Some(&scope.source_kind))
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite tree view state 读取失败: {error}"))
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let Some(record) = find_tree_view_state_record(&preferences, &scope) else {
|
||||
return Ok(Json(response_payload(
|
||||
&actor_id,
|
||||
&scope,
|
||||
"default",
|
||||
None,
|
||||
default_state(&scope),
|
||||
)));
|
||||
};
|
||||
let parsed = serde_json::from_str::<Value>(&record.value_json).map_err(|error| {
|
||||
WebError::internal(format!("SQLite tree view state JSON 无效: {error}"))
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let normalized = normalize_state(&context, &scope, parsed)?;
|
||||
Ok(Json(response_payload(
|
||||
&actor_id,
|
||||
&scope,
|
||||
"sqlite",
|
||||
Some(record.revision),
|
||||
normalized,
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) async fn put_tree_view_state(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<TreeViewStateUpdateRequest>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
ensure_actor_user(&state, &actor_id)?;
|
||||
let scope = resolve_scope(
|
||||
&context,
|
||||
&request.workspace_id,
|
||||
&request.source_kind,
|
||||
&request.tree_kind,
|
||||
&request.root_uri,
|
||||
&request.scope,
|
||||
)?;
|
||||
let normalized = normalize_state(&context, &scope, request.state)?;
|
||||
let record = state
|
||||
.control_plane()
|
||||
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
|
||||
id: None,
|
||||
user_id: actor_id.clone(),
|
||||
workspace_id: Some(scope.workspace_id.clone()),
|
||||
source_kind: Some(scope.source_kind.clone()),
|
||||
scope_kind: SIDEBAR_TREE_SCOPE_KIND.to_string(),
|
||||
scope_id: scope.scope_id.clone(),
|
||||
key: SIDEBAR_TREE_VIEW_STATE_KEY.to_string(),
|
||||
value_json: normalized.to_string(),
|
||||
})
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"tree_view_state_write_failed",
|
||||
format!("写入 tree view state 失败: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
Ok(Json(response_payload(
|
||||
&actor_id,
|
||||
&scope,
|
||||
"sqlite",
|
||||
Some(record.revision),
|
||||
normalized,
|
||||
)))
|
||||
}
|
||||
|
||||
fn find_tree_view_state_record<'a>(
|
||||
preferences: &'a [UserUiPreferenceRecord],
|
||||
scope: &TreeViewStateScope,
|
||||
) -> Option<&'a UserUiPreferenceRecord> {
|
||||
preferences.iter().find(|preference| {
|
||||
preference.scope_kind == SIDEBAR_TREE_SCOPE_KIND
|
||||
&& preference.scope_id == scope.scope_id
|
||||
&& preference.key == SIDEBAR_TREE_VIEW_STATE_KEY
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_scope(
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
source_kind: &str,
|
||||
tree_kind: &str,
|
||||
root_uri: &str,
|
||||
scope: &str,
|
||||
) -> Result<TreeViewStateScope, WebError> {
|
||||
let source_kind = normalize_or_default(source_kind, "convex_workspace");
|
||||
let tree_kind = normalize_tree_kind(context, tree_kind)?;
|
||||
let root_uri = root_uri.trim().to_string();
|
||||
let scope = normalize_or_default(scope, "root");
|
||||
let workspace_id = if source_kind == "local_folder" {
|
||||
local_workspace_id_from_root_uri(&root_uri).map_err(|error| error.with_context(context))?
|
||||
} else {
|
||||
normalize_or_default(workspace_id, "default")
|
||||
};
|
||||
let scope_id = format!(
|
||||
"{}:{}:{}",
|
||||
tree_kind,
|
||||
stable_hash_16(&root_uri),
|
||||
stable_hash_16(&scope)
|
||||
);
|
||||
Ok(TreeViewStateScope {
|
||||
workspace_id,
|
||||
source_kind,
|
||||
tree_kind,
|
||||
root_uri,
|
||||
scope,
|
||||
scope_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_tree_kind(context: &RequestContext, tree_kind: &str) -> Result<String, WebError> {
|
||||
match tree_kind.trim() {
|
||||
"filetree" => Ok("filetree".to_string()),
|
||||
"pagetree" => Ok("pagetree".to_string()),
|
||||
_ => Err(WebError::bad_request_code(
|
||||
"tree_view_state_tree_kind_invalid",
|
||||
"treeKind 只允许 filetree 或 pagetree",
|
||||
)
|
||||
.with_context(context)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_state(
|
||||
context: &RequestContext,
|
||||
scope: &TreeViewStateScope,
|
||||
value: Value,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut object = value
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("tree_view_state_invalid", "state 必须是对象")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let schema_version = object
|
||||
.get("schemaVersion")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1);
|
||||
if schema_version != 1 {
|
||||
return Err(WebError::bad_request_code(
|
||||
"tree_view_state_schema_invalid",
|
||||
"schemaVersion 必须为 1",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
for derived_key in [
|
||||
"rowsByParent",
|
||||
"loadedParents",
|
||||
"loadingParents",
|
||||
"dirtyParents",
|
||||
"staleParents",
|
||||
"revisionByParent",
|
||||
"latestGenerationByParent",
|
||||
] {
|
||||
if object.contains_key(derived_key) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"tree_view_state_contains_derived_cache",
|
||||
format!("tree view state 不能包含派生运行态 {derived_key}"),
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
}
|
||||
object.insert("schemaVersion".to_string(), json!(1));
|
||||
object.insert("treeKind".to_string(), json!(scope.tree_kind));
|
||||
object.insert("rootUri".to_string(), json!(scope.root_uri));
|
||||
object.insert("scope".to_string(), json!(scope.scope));
|
||||
object.insert(
|
||||
"expandedIds".to_string(),
|
||||
json!(bounded_string_array(object.get("expandedIds"), MAX_STATE_ITEMS)),
|
||||
);
|
||||
object.insert(
|
||||
"expandedRelativePaths".to_string(),
|
||||
json!(bounded_string_array(
|
||||
object.get("expandedRelativePaths"),
|
||||
MAX_STATE_ITEMS
|
||||
)),
|
||||
);
|
||||
for key in ["selectedId", "focusedId", "activeId"] {
|
||||
object.insert(key.to_string(), json!(bounded_string(object.get(key))));
|
||||
}
|
||||
object.insert(
|
||||
"scrollTop".to_string(),
|
||||
json!(object
|
||||
.get("scrollTop")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0)),
|
||||
);
|
||||
if !object.contains_key("updatedAtMs") {
|
||||
object.insert("updatedAtMs".to_string(), json!(0));
|
||||
}
|
||||
Ok(Value::Object(object))
|
||||
}
|
||||
|
||||
fn default_state(scope: &TreeViewStateScope) -> Value {
|
||||
normalize_state(
|
||||
&RequestContext::from_http_parts(
|
||||
&axum::http::Method::GET,
|
||||
&"/api/tree/view-state".parse().expect("static uri"),
|
||||
&axum::http::HeaderMap::new(),
|
||||
),
|
||||
scope,
|
||||
json!({ "schemaVersion": 1 }),
|
||||
)
|
||||
.expect("default tree view state is valid")
|
||||
}
|
||||
|
||||
fn response_payload(
|
||||
actor_id: &str,
|
||||
scope: &TreeViewStateScope,
|
||||
source: &str,
|
||||
revision: Option<i64>,
|
||||
state: Value,
|
||||
) -> Value {
|
||||
json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"result": {
|
||||
"userId": actor_id,
|
||||
"workspaceId": scope.workspace_id,
|
||||
"sourceKind": scope.source_kind,
|
||||
"treeKind": scope.tree_kind,
|
||||
"rootUri": scope.root_uri,
|
||||
"scope": scope.scope,
|
||||
"scopeId": scope.scope_id,
|
||||
"scopeKind": SIDEBAR_TREE_SCOPE_KIND,
|
||||
"key": SIDEBAR_TREE_VIEW_STATE_KEY,
|
||||
"source": source,
|
||||
"revision": revision,
|
||||
"state": state
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn bounded_string_array(value: Option<&Value>, limit: usize) -> Vec<String> {
|
||||
value
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(|item| item.chars().take(MAX_ID_LENGTH).collect::<String>())
|
||||
.take(limit)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn bounded_string(value: Option<&Value>) -> String {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.chars().take(MAX_ID_LENGTH).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn normalize_or_default(value: &str, default: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
default.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn stable_hash_16(value: &str) -> String {
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for byte in value.trim().as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("{hash:016x}")
|
||||
}
|
||||
|
||||
fn require_actor_id(state: &AppState, context: &RequestContext) -> Result<String, WebError> {
|
||||
current_actor_id(state, context)
|
||||
.filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous")
|
||||
.ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"tree_view_state_auth_required",
|
||||
"tree view state 需要登录用户",
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_actor_user(state: &AppState, actor_id: &str) -> Result<(), WebError> {
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some(actor_id.to_string()),
|
||||
email: None,
|
||||
username: actor_id.to_string(),
|
||||
display_name: actor_id.to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|error| WebError::internal(format!("确保用户记录失败: {error}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stable_scope_id_separates_tree_kind_and_scope() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::GET,
|
||||
&"/api/tree/view-state".parse().expect("uri"),
|
||||
&axum::http::HeaderMap::new(),
|
||||
);
|
||||
let filetree = resolve_scope(
|
||||
&context,
|
||||
"local:_mnt_Data1T_mnote",
|
||||
"local_folder",
|
||||
"filetree",
|
||||
"file:///mnt/Data1T/mnote",
|
||||
"design",
|
||||
)
|
||||
.expect("filetree scope");
|
||||
let pagetree = resolve_scope(
|
||||
&context,
|
||||
"local:_mnt_Data1T_mnote",
|
||||
"local_folder",
|
||||
"pagetree",
|
||||
"file:///mnt/Data1T/mnote",
|
||||
"design",
|
||||
)
|
||||
.expect("pagetree scope");
|
||||
assert_ne!(filetree.scope_id, pagetree.scope_id);
|
||||
assert!(filetree.scope_id.starts_with("filetree:"));
|
||||
assert!(pagetree.scope_id.starts_with("pagetree:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_state_rejects_derived_cache() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::GET,
|
||||
&"/api/tree/view-state".parse().expect("uri"),
|
||||
&axum::http::HeaderMap::new(),
|
||||
);
|
||||
let root = std::env::current_dir().expect("cwd");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let scope = resolve_scope(
|
||||
&context,
|
||||
"ws",
|
||||
"local_folder",
|
||||
"filetree",
|
||||
&root_uri,
|
||||
"root",
|
||||
)
|
||||
.expect("scope");
|
||||
let error = normalize_state(
|
||||
&context,
|
||||
&scope,
|
||||
json!({ "schemaVersion": 1, "rowsByParent": {} }),
|
||||
)
|
||||
.expect_err("derived cache rejected");
|
||||
assert_eq!(error.code(), "tree_view_state_contains_derived_cache");
|
||||
}
|
||||
}
|
||||
@@ -2610,6 +2610,128 @@ mod tests {
|
||||
assert_eq!(payload["result"]["sources"]["layoutDensity"], "workspace");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_view_state_api_is_user_scoped_and_tree_scope_isolated() {
|
||||
let app = app();
|
||||
let put_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/tree/view-state")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "alice")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"userId": "mallory",
|
||||
"workspaceId": "default",
|
||||
"sourceKind": "local_folder",
|
||||
"treeKind": "filetree",
|
||||
"rootUri": "file:///mnt/Data1T/mnote",
|
||||
"scope": "design",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"treeKind": "filetree",
|
||||
"rootUri": "file:///mnt/Data1T/mnote",
|
||||
"scope": "design",
|
||||
"expandedRelativePaths": ["design/05-editor-mainline"],
|
||||
"expandedIds": [],
|
||||
"selectedId": "local:folder:design/05-editor-mainline",
|
||||
"focusedId": "",
|
||||
"activeId": "",
|
||||
"scrollTop": 12
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(put_response.status(), StatusCode::OK);
|
||||
let put_body = to_bytes(put_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let put_payload: Value = serde_json::from_slice(&put_body).expect("json");
|
||||
assert_eq!(put_payload["result"]["userId"], "alice");
|
||||
assert_ne!(put_payload["result"]["workspaceId"], "default");
|
||||
assert!(put_payload["result"]["scopeId"]
|
||||
.as_str()
|
||||
.expect("scope id")
|
||||
.starts_with("filetree:"));
|
||||
|
||||
let get_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=filetree&rootUri=file:///mnt/Data1T/mnote&scope=design")
|
||||
.header("x-mnote-actor-id", "alice")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(get_response.status(), StatusCode::OK);
|
||||
let get_body = to_bytes(get_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let get_payload: Value = serde_json::from_slice(&get_body).expect("json");
|
||||
assert_eq!(get_payload["result"]["source"], "sqlite");
|
||||
assert_eq!(
|
||||
get_payload["result"]["state"]["expandedRelativePaths"][0],
|
||||
"design/05-editor-mainline"
|
||||
);
|
||||
|
||||
let page_tree_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=pagetree&rootUri=file:///mnt/Data1T/mnote&scope=design")
|
||||
.header("x-mnote-actor-id", "alice")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(page_tree_response.status(), StatusCode::OK);
|
||||
let page_tree_body = to_bytes(page_tree_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let page_tree_payload: Value = serde_json::from_slice(&page_tree_body).expect("json");
|
||||
assert_eq!(page_tree_payload["result"]["source"], "default");
|
||||
assert_eq!(
|
||||
page_tree_payload["result"]["state"]["expandedRelativePaths"]
|
||||
.as_array()
|
||||
.expect("array")
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
|
||||
let bob_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=filetree&rootUri=file:///mnt/Data1T/mnote&scope=design")
|
||||
.header("x-mnote-actor-id", "bob")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(bob_response.status(), StatusCode::OK);
|
||||
let bob_body = to_bytes(bob_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let bob_payload: Value = serde_json::from_slice(&bob_body).expect("json");
|
||||
assert_eq!(bob_payload["result"]["source"], "default");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() {
|
||||
let root =
|
||||
|
||||
@@ -346,6 +346,29 @@ mod tests {
|
||||
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("fetch('/api/user/access-policy'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_live_apply_runtime_uses_persisted_tree_view_state() {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("SIDEBAR_TREE_VIEW_STATE_KEY"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function loadSidebarTreeViewState"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function persistSidebarTreeViewState"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function applySidebarTreeViewState"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/view-state"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("mnote.sidebarTreeViewState.v1:{userId}:{workspaceId}:{sourceKind}:{treeKind}:{rootUriHash}:{scopeHash}"));
|
||||
|
||||
let render_page_rows = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderPageRows");
|
||||
assert!(render_page_rows.contains("sidebarTreeViewStateFor('pagetree'"));
|
||||
assert!(render_page_rows.contains("expandedIds"));
|
||||
assert!(
|
||||
!render_page_rows.contains("var expanded = expandable && item.expandedByDefault !== false"),
|
||||
"PageTree 不能只依赖 projection expandedByDefault 决定展开"
|
||||
);
|
||||
|
||||
let render_file_rows = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderFileRows");
|
||||
assert!(render_file_rows.contains("sidebarTreeViewStateFor('filetree'"));
|
||||
assert!(render_file_rows.contains("expandedRelativePaths"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_layout_hides_public_state_and_exposes_sidebar_shortcut_star() {
|
||||
let html = crate::ssr::render_view(leptos::view! {
|
||||
@@ -1163,6 +1186,35 @@ mod tests {
|
||||
.contains("if (!isLatestPageTreeRequest(generation))"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_refreshes_scoped_page_tree_projection() {
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("sidebarUrl.searchParams.set('parentRelativePath', fileTreeScope)"),
|
||||
"scoped filetree 入口刷新 PageTree 时必须请求同 scope projection"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("sidebarTreeViewStateFor('pagetree')"),
|
||||
"SSR 初始 PageTree 也必须触发 pagetree 用户保存态加载"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_page_create_does_not_full_refresh_local_folder_sidebar() {
|
||||
assert!(
|
||||
!SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("await refreshLocalFolderSidebarSnapshot();"),
|
||||
"本地新建页面不能全量刷新 sidebar,否则会折叠/重建其它已展开节点"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("await refreshLocalFolderAfterCommand('create'"),
|
||||
"本地新建页面应等待局部 parent refresh,而不是全量刷新 sidebar"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("selectSidebarFileTreeDocument(nextDocumentId"),
|
||||
"本地新建页面仍应只 reveal/select 新节点"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_prefers_command_affected_parents() {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
|
||||
@@ -622,6 +622,7 @@ html[data-mnote-sidebar-resizing="true"] .mnote-sidebar-resizer::before {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav a,
|
||||
@@ -1374,7 +1375,7 @@ body {
|
||||
background: var(--atelier-sidebar);
|
||||
border-right: 0;
|
||||
box-shadow: none;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mnote-sidebar-header,
|
||||
@@ -1439,6 +1440,13 @@ body {
|
||||
padding: 0 8px 18px;
|
||||
}
|
||||
|
||||
.wolai-my-pages-section {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wolai-section-title,
|
||||
.wolai-sidebar-tabs {
|
||||
height: 32px;
|
||||
@@ -1496,7 +1504,10 @@ body {
|
||||
}
|
||||
|
||||
.wolai-sidebar-tab-panels {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.wolai-sidebar-tab-panel[hidden] {
|
||||
@@ -1996,6 +2007,7 @@ body {
|
||||
}
|
||||
|
||||
.wolai-sidebar-footer {
|
||||
flex: 0 0 auto;
|
||||
border-top: 0;
|
||||
background: var(--atelier-sidebar);
|
||||
}
|
||||
@@ -4470,6 +4482,15 @@ mod tests {
|
||||
assert!(MNOTE_CSS.contains("::-webkit-scrollbar-thumb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_scroll_is_contained_to_tree_panels() {
|
||||
assert!(MNOTE_CSS.contains(".mnote-sidebar,\n.wolai-sidebar {\n width: var(--mnote-sidebar-width, 248px);\n background: var(--atelier-sidebar);\n border-right: 0;\n box-shadow: none;\n overflow: hidden;"));
|
||||
assert!(MNOTE_CSS.contains(".wolai-sidebar-body {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;"));
|
||||
assert!(MNOTE_CSS.contains(".wolai-my-pages-section {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;"));
|
||||
assert!(MNOTE_CSS.contains(".wolai-sidebar-tab-panels {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n overscroll-behavior: contain;"));
|
||||
assert!(MNOTE_CSS.contains(".wolai-sidebar-footer {\n flex: 0 0 auto;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_policy_rows_wrap_long_grant_values() {
|
||||
assert!(MNOTE_CSS.contains(
|
||||
|
||||
Reference in New Issue
Block a user