Implement sidebar tree view state persistence

This commit is contained in:
lix-2026
2026-05-27 16:32:05 +08:00
parent bd7470d03e
commit e43efacbce
15 changed files with 1875 additions and 17 deletions
+56 -2
View File
@@ -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);
}
+5
View File
@@ -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 =