Improve local filetree view state and sidebar performance

This commit is contained in:
lix-2026
2026-05-27 11:31:12 +08:00
parent 58e2fdb5d8
commit 3ae33cc21d
56 changed files with 8614 additions and 461 deletions
@@ -0,0 +1,489 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::page_aggregate::{PageAggregate, PageOptions};
use crate::routes::gateway::current_actor_id;
use crate::routes::local_folder_source::{
local_root_has_workspace_manifest, 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::Value;
use serde_json::{json, Map};
use std::collections::BTreeMap;
pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space";
pub(crate) const SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER: &str = "external_local_folder";
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UiPreferencesQuery {
#[serde(default, alias = "workspace_id")]
workspace_id: String,
#[serde(default, alias = "source_kind")]
source_kind: String,
#[serde(default, alias = "root_uri")]
root_uri: String,
#[serde(default, alias = "document_id")]
document_id: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UiPreferencesUpdateRequest {
#[serde(default, alias = "workspace_id")]
workspace_id: String,
#[serde(default, alias = "source_kind")]
source_kind: String,
#[serde(default, alias = "root_uri")]
root_uri: String,
#[serde(default, alias = "document_id")]
document_id: String,
#[serde(default)]
updates: BTreeMap<String, Value>,
}
#[derive(Debug, Clone)]
struct PagePreferenceScope {
workspace_id: String,
source_kind: String,
source_family: String,
document_id: String,
}
#[derive(Debug, Clone)]
struct EffectivePagePreferences {
scope: PagePreferenceScope,
page_options: PageOptions,
sources: BTreeMap<String, String>,
}
pub(crate) async fn effective_preferences(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<UiPreferencesQuery>,
) -> Result<Json<Value>, WebError> {
let actor_id = require_actor_id(&state, &context)?;
let effective = resolve_effective_page_preferences(
&state,
&actor_id,
&query.workspace_id,
&query.source_kind,
&query.root_uri,
&query.document_id,
PageOptions::default(),
)?;
Ok(Json(effective_preferences_payload(effective)))
}
pub(crate) async fn update_preferences(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<UiPreferencesUpdateRequest>,
) -> Result<Json<Value>, WebError> {
let actor_id = require_actor_id(&state, &context)?;
let result = update_page_preferences_from_value(
&state,
&context,
&actor_id,
&request.workspace_id,
&request.source_kind,
&request.root_uri,
&request.document_id,
&Value::Object(request.updates.into_iter().collect()),
)?;
Ok(Json(json!({
"ok": true,
"owner": "mnote-web",
"result": result,
})))
}
pub(crate) fn update_page_preferences_from_value(
state: &AppState,
context: &RequestContext,
actor_id: &str,
workspace_id: &str,
source_kind: &str,
root_uri: &str,
document_id: &str,
updates: &Value,
) -> Result<Value, WebError> {
ensure_actor_user(state, actor_id)?;
let scope = page_preference_scope(workspace_id, source_kind, root_uri, document_id)?;
let update_map = updates.as_object().ok_or_else(|| {
WebError::bad_request_code("ui_preference_updates_invalid", "updates 必须是对象")
.with_context(context)
})?;
for (key, value) in update_map {
let Some((scope_kind, scope_id)) = preference_scope_for_key(key, &scope) else {
continue;
};
state
.control_plane()
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
id: None,
user_id: actor_id.to_string(),
workspace_id: preference_workspace_for_scope(&scope.workspace_id, &scope_kind),
source_kind: preference_source_kind_for_scope(&scope.source_kind, &scope_kind),
scope_kind,
scope_id,
key: key.trim().to_string(),
value_json: value.to_string(),
})
.map_err(|error| {
WebError::bad_request_code(
"ui_preference_write_failed",
format!("写入 UI 偏好失败: {error}"),
)
.with_context(context)
})?;
}
let effective = resolve_effective_page_preferences(
state,
actor_id,
workspace_id,
source_kind,
root_uri,
document_id,
PageOptions::default(),
)?;
Ok(effective_preferences_payload(effective)["result"].clone())
}
pub(crate) fn source_family_for_page(
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> Result<String, WebError> {
if source_kind.map(str::trim) != Some("local_folder") {
return Ok("workspace".to_string());
}
let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER.to_string());
};
if local_root_has_workspace_manifest(root_uri)? {
Ok(SOURCE_FAMILY_MY_SPACE.to_string())
} else {
Ok(SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER.to_string())
}
}
pub(crate) fn apply_effective_page_preferences(
state: &AppState,
context: &RequestContext,
aggregate: &mut PageAggregate,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> Result<(), WebError> {
let actor_id = context.auth.actor_id.trim();
if actor_id.is_empty() || actor_id == "anonymous" {
return Ok(());
}
let effective = resolve_effective_page_preferences(
state,
actor_id,
&aggregate.identity.workspace_id,
source_kind.unwrap_or_default(),
root_uri.unwrap_or_default(),
&aggregate.identity.document_id,
aggregate.layout.page_options.clone(),
)?;
aggregate.layout.page_options = effective.page_options;
aggregate.layout_options = serde_json::to_value(&aggregate.layout.page_options)
.unwrap_or_else(|_| serde_json::json!({}));
Ok(())
}
fn resolve_effective_page_preferences(
state: &AppState,
actor_id: &str,
workspace_id: &str,
source_kind: &str,
root_uri: &str,
document_id: &str,
base_options: PageOptions,
) -> Result<EffectivePagePreferences, WebError> {
let scope = page_preference_scope(workspace_id, source_kind, root_uri, document_id)?;
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 UI 偏好读取失败: {error}")))?;
let mut page_options = base_options;
if scope.source_family == SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER {
page_options.hide_title_header = true;
}
let mut sources = BTreeMap::new();
apply_preference_records(&mut page_options, &mut sources, &scope, &preferences)?;
Ok(EffectivePagePreferences {
scope,
page_options,
sources,
})
}
fn apply_preference_records(
page_options: &mut PageOptions,
sources: &mut BTreeMap<String, String>,
scope: &PagePreferenceScope,
preferences: &[UserUiPreferenceRecord],
) -> Result<(), WebError> {
for scope_kind in ["global", "source_family", "workspace", "document"] {
for preference in preferences
.iter()
.filter(|preference| preference.scope_kind.trim() == scope_kind)
{
let scope_matches = match scope_kind {
"global" => preference.scope_id.trim() == "default",
"source_family" => preference.scope_id.trim() == scope.source_family,
"workspace" => preference.scope_id.trim() == scope.workspace_id,
"document" => preference.scope_id.trim() == scope.document_id,
_ => false,
};
if !scope_matches {
continue;
}
let value = serde_json::from_str::<Value>(&preference.value_json).map_err(|error| {
WebError::internal(format!(
"SQLite UI 偏好 JSON 无效 {}: {error}",
preference.key
))
})?;
if apply_page_option_value(page_options, &preference.key, &value) {
sources.insert(preference.key.clone(), scope_kind.to_string());
}
}
}
Ok(())
}
fn page_preference_scope(
workspace_id: &str,
source_kind: &str,
root_uri: &str,
document_id: &str,
) -> Result<PagePreferenceScope, WebError> {
let source_kind = source_kind.trim();
let source_kind = if source_kind.is_empty() {
"convex_workspace"
} else {
source_kind
};
let workspace_id = workspace_id.trim().to_string().if_empty_else(|| {
if source_kind == "local_folder" {
local_workspace_id_from_root_uri(root_uri).unwrap_or_else(|_| "local-folder".into())
} else {
"default".into()
}
});
Ok(PagePreferenceScope {
workspace_id,
source_kind: source_kind.to_string(),
source_family: source_family_for_page(Some(source_kind), Some(root_uri))?,
document_id: document_id.trim().to_string(),
})
}
fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(String, String)> {
match key.trim() {
"hideTitleHeader" | "hide_title_header" => {
Some(("source_family".to_string(), scope.source_family.clone()))
}
"showHeadingNumbers" | "show_heading_numbers" | "showWordCount" | "show_word_count" => {
Some(("global".to_string(), "default".to_string()))
}
"wideLayout"
| "wide_layout"
| "smallText"
| "small_text"
| "layoutDensity"
| "layout_density"
| "pageFont"
| "page_font"
| "showToc"
| "show_toc"
| "showStructure"
| "show_structure"
| "collapseBacklinks"
| "collapse_backlinks"
| "hideChildPages"
| "hide_child_pages"
| "showBlockRefCount"
| "show_block_ref_count" => Some(("workspace".to_string(), scope.workspace_id.clone())),
_ => None,
}
}
fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Option<String> {
if scope_kind == "workspace" || scope_kind == "document" {
Some(workspace_id.to_string())
} else {
None
}
}
fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Option<String> {
if scope_kind == "workspace" || scope_kind == "document" {
Some(source_kind.to_string())
} else {
None
}
}
fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
let page_options = serde_json::to_value(&effective.page_options).unwrap_or_else(|_| json!({}));
let sources = effective
.sources
.into_iter()
.map(|(key, value)| (key, Value::String(value)))
.collect::<Map<String, Value>>();
json!({
"ok": true,
"owner": "mnote-web",
"result": {
"scope": {
"sourceFamily": effective.scope.source_family,
"workspaceId": effective.scope.workspace_id,
"sourceKind": effective.scope.source_kind,
"documentId": effective.scope.document_id,
},
"pageOptions": page_options,
"sources": Value::Object(sources),
}
})
}
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,
"ui_preference_auth_required",
"UI 偏好需要登录用户",
)
.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!("SQLite 用户初始化失败: {error}")))
}
fn apply_page_option_value(options: &mut PageOptions, key: &str, value: &Value) -> bool {
match key {
"hideTitleHeader" | "hide_title_header" => {
if let Some(value) = value.as_bool() {
options.hide_title_header = value;
return true;
}
}
"showHeadingNumbers" | "show_heading_numbers" => {
if let Some(value) = value.as_bool() {
options.show_heading_numbers = value;
return true;
}
}
"wideLayout" | "wide_layout" => {
if let Some(value) = value.as_bool() {
options.wide_layout = value;
return true;
}
}
"smallText" | "small_text" => {
if let Some(value) = value.as_bool() {
options.small_text = value;
return true;
}
}
"layoutDensity" | "layout_density" => {
if let Some(value) = value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
options.layout_density = value.to_string();
return true;
}
}
"pageFont" | "page_font" => {
if let Some(value) = value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
options.page_font = value.to_string();
return true;
}
}
"showToc" | "show_toc" => {
if let Some(value) = value.as_bool() {
options.show_toc = value;
return true;
}
}
"showStructure" | "show_structure" => {
if let Some(value) = value.as_bool() {
options.show_structure = value;
return true;
}
}
"showWordCount" | "show_word_count" => {
if let Some(value) = value.as_bool() {
options.show_word_count = value;
return true;
}
}
"collapseBacklinks" | "collapse_backlinks" => {
if let Some(value) = value.as_bool() {
options.collapse_backlinks = value;
return true;
}
}
"hideChildPages" | "hide_child_pages" => {
if let Some(value) = value.as_bool() {
options.hide_child_pages = value;
return true;
}
}
"showBlockRefCount" | "show_block_ref_count" => {
if let Some(value) = value.as_bool() {
options.show_block_ref_count = value;
return true;
}
}
_ => {}
}
false
}
trait EmptyStringExt {
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
}
impl EmptyStringExt for String {
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
if self.trim().is_empty() {
fallback()
} else {
self
}
}
}