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
+447 -3
View File
@@ -12,8 +12,9 @@ use crate::model::{
AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput,
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup,
DirectoryGrantRecord, OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession,
ShareLinkRecord, SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertSyncStateInput, UpsertUserInput, UserRecord, WorkspaceRecord,
ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput,
UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
};
use crate::store::ControlPlaneStore;
@@ -175,6 +176,60 @@ fn row_to_audit_log(row: &rusqlite::Row<'_>) -> rusqlite::Result<AuditLogRecord>
})
}
fn row_to_sidebar_shortcut(row: &rusqlite::Row<'_>) -> rusqlite::Result<SidebarShortcutRecord> {
Ok(SidebarShortcutRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
root_uri: row.get(3)?,
kind: row.get(4)?,
source_kind: row.get(5)?,
target_id: row.get(6)?,
relative_path: row.get(7)?,
document_id: row.get(8)?,
title: row.get(9)?,
icon: row.get(10)?,
sort_order: row.get(11)?,
status: row.get(12)?,
metadata_json: row.get(13)?,
created_at: row.get(14)?,
updated_at: row.get(15)?,
revision: row.get(16)?,
})
}
fn empty_string_to_option(value: String) -> Option<String> {
if value.trim().is_empty() {
None
} else {
Some(value)
}
}
fn option_to_stored_text(value: Option<String>) -> String {
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.unwrap_or_default()
}
fn row_to_user_ui_preference(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserUiPreferenceRecord> {
Ok(UserUiPreferenceRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: empty_string_to_option(row.get(2)?),
source_kind: empty_string_to_option(row.get(3)?),
scope_kind: row.get(4)?,
scope_id: row.get(5)?,
key: row.get(6)?,
value_json: row.get(7)?,
status: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
revision: row.get(11)?,
})
}
fn row_to_ai_policy(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiPolicyRecord> {
Ok(AiPolicyRecord {
id: row.get(0)?,
@@ -1065,6 +1120,268 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
Ok(())
}
fn upsert_sidebar_shortcut(
&self,
input: UpsertSidebarShortcutInput,
) -> Result<SidebarShortcutRecord, ControlPlaneError> {
let user_id = input.user_id.trim().to_string();
let workspace_id = input.workspace_id.trim().to_string();
let root_uri = input
.root_uri
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let kind = input.kind.trim().to_string();
let source_kind = input.source_kind.trim().to_string();
let target_id = input.target_id.trim().to_string();
let title = input.title.trim().to_string();
if user_id.is_empty()
|| workspace_id.is_empty()
|| kind.is_empty()
|| source_kind.is_empty()
|| target_id.is_empty()
|| title.is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"sidebar shortcut user/workspace/kind/target/title 不能为空".to_string(),
));
}
if kind != "page" && kind != "folder" {
return Err(ControlPlaneError::InvalidInput(
"sidebar shortcut kind 只能是 page 或 folder".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let now = now_text();
conn.execute(
"INSERT INTO sidebar_shortcuts (
id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path,
document_id, title, icon, sort_order, status, metadata_json, created_at, updated_at, revision
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'active', ?13, ?14, ?15, 1)
ON CONFLICT(user_id, workspace_id, kind, target_id)
DO UPDATE SET
root_uri = excluded.root_uri,
relative_path = excluded.relative_path,
document_id = excluded.document_id,
title = excluded.title,
icon = excluded.icon,
sort_order = excluded.sort_order,
status = 'active',
metadata_json = excluded.metadata_json,
updated_at = excluded.updated_at,
revision = sidebar_shortcuts.revision + 1",
params![
input.id.unwrap_or_else(|| new_id("shortcut")),
user_id,
workspace_id,
root_uri,
kind,
source_kind,
target_id,
input.relative_path,
input.document_id,
title,
input.icon,
input.sort_order,
input.metadata_json,
now,
now
],
)?;
let record = conn.query_row(
"SELECT id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path,
document_id, title, icon, sort_order, status, metadata_json, created_at,
updated_at, revision
FROM sidebar_shortcuts
WHERE user_id = ?1 AND workspace_id = ?2 AND kind = ?3 AND target_id = ?4
LIMIT 1",
params![user_id, workspace_id, kind, target_id],
row_to_sidebar_shortcut,
)?;
Ok(record)
}
fn list_sidebar_shortcuts(
&self,
user_id: &str,
workspace_id: &str,
) -> Result<Vec<SidebarShortcutRecord>, ControlPlaneError> {
let user_id = user_id.trim();
let workspace_id = workspace_id.trim();
if user_id.is_empty() || workspace_id.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path,
document_id, title, icon, sort_order, status, metadata_json, created_at,
updated_at, revision
FROM sidebar_shortcuts
WHERE user_id = ?1 AND workspace_id = ?2 AND status = 'active'
ORDER BY sort_order ASC, created_at ASC",
)?;
let rows = stmt
.query_map(params![user_id, workspace_id], row_to_sidebar_shortcut)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn list_sidebar_shortcuts_with_global_local(
&self,
user_id: &str,
workspace_id: &str,
) -> Result<Vec<SidebarShortcutRecord>, ControlPlaneError> {
let user_id = user_id.trim();
let workspace_id = workspace_id.trim();
if user_id.is_empty() || workspace_id.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path,
document_id, title, icon, sort_order, status, metadata_json, created_at,
updated_at, revision
FROM sidebar_shortcuts
WHERE user_id = ?1
AND status = 'active'
AND (workspace_id = ?2 OR source_kind = 'local_folder')
ORDER BY sort_order ASC, created_at ASC",
)?;
let rows = stmt
.query_map(params![user_id, workspace_id], row_to_sidebar_shortcut)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn delete_sidebar_shortcut(
&self,
user_id: &str,
shortcut_id: &str,
) -> Result<(), ControlPlaneError> {
let user_id = user_id.trim();
let shortcut_id = shortcut_id.trim();
if user_id.is_empty() || shortcut_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"sidebar shortcut user_id/shortcut_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let changed = conn.execute(
"UPDATE sidebar_shortcuts
SET status = 'removed', updated_at = ?1, revision = revision + 1
WHERE id = ?2 AND user_id = ?3 AND status = 'active'",
params![now_text(), shortcut_id, user_id],
)?;
if changed == 0 {
return Err(ControlPlaneError::NotFound(format!(
"sidebar shortcut not found: {shortcut_id}"
)));
}
Ok(())
}
fn upsert_user_ui_preference(
&self,
input: UpsertUserUiPreferenceInput,
) -> Result<UserUiPreferenceRecord, ControlPlaneError> {
let user_id = input.user_id.trim().to_string();
let workspace_id = option_to_stored_text(input.workspace_id);
let source_kind = option_to_stored_text(input.source_kind);
let scope_kind = input.scope_kind.trim().to_string();
let scope_id = input.scope_id.trim().to_string();
let key = input.key.trim().to_string();
if user_id.is_empty() || scope_kind.is_empty() || scope_id.is_empty() || key.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"user UI preference user/scope/key 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.value_json)?;
let conn = self.conn.lock().unwrap();
let now = now_text();
conn.execute(
"INSERT INTO user_ui_preferences (
id, user_id, workspace_id, source_kind, scope_kind, scope_id, key,
value_json, status, created_at, updated_at, revision
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'active', ?9, ?10, 1)
ON CONFLICT(user_id, workspace_id, source_kind, scope_kind, scope_id, key)
DO UPDATE SET
value_json = excluded.value_json,
status = 'active',
updated_at = excluded.updated_at,
revision = user_ui_preferences.revision + 1",
params![
input.id.unwrap_or_else(|| new_id("ui_pref")),
user_id,
workspace_id,
source_kind,
scope_kind,
scope_id,
key,
input.value_json,
now,
now
],
)?;
let record = conn.query_row(
"SELECT id, user_id, workspace_id, source_kind, scope_kind, scope_id, key,
value_json, status, created_at, updated_at, revision
FROM user_ui_preferences
WHERE user_id = ?1
AND workspace_id = ?2
AND source_kind = ?3
AND scope_kind = ?4
AND scope_id = ?5
AND key = ?6
LIMIT 1",
params![
user_id,
workspace_id,
source_kind,
scope_kind,
scope_id,
key
],
row_to_user_ui_preference,
)?;
Ok(record)
}
fn list_user_ui_preferences(
&self,
user_id: &str,
workspace_id: Option<&str>,
source_kind: Option<&str>,
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Ok(Vec::new());
}
let workspace_id = workspace_id.map(str::trim).unwrap_or_default();
let source_kind = source_kind.map(str::trim).unwrap_or_default();
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, source_kind, scope_kind, scope_id, key,
value_json, status, created_at, updated_at, revision
FROM user_ui_preferences
WHERE user_id = ?1
AND status = 'active'
AND (workspace_id = '' OR workspace_id = ?2)
AND (source_kind = '' OR source_kind = ?3)
ORDER BY created_at ASC",
)?;
let rows = stmt
.query_map(
params![user_id, workspace_id, source_kind],
row_to_user_ui_preference,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn upsert_ai_policy(
&self,
input: UpsertAiPolicyInput,
@@ -1668,7 +1985,7 @@ mod tests {
use crate::model::{
password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput,
CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertSyncStateInput,
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserUiPreferenceInput,
};
fn store() -> SqliteControlPlaneStore {
@@ -1960,6 +2277,133 @@ mod tests {
assert_eq!(pending[0].id, second.id);
}
#[test]
fn sidebar_shortcuts_are_user_scoped_and_upserted_by_target() {
let store = store();
create_user(&store, "alice");
create_user(&store, "bob");
let workspace = store.ensure_default_workspace("alice").expect("workspace");
let first = store
.upsert_sidebar_shortcut(UpsertSidebarShortcutInput {
id: None,
user_id: "alice".to_string(),
workspace_id: workspace.id.clone(),
root_uri: Some("file:///tmp/mnote".to_string()),
kind: "folder".to_string(),
source_kind: "local_folder".to_string(),
target_id: "local-dir:design".to_string(),
relative_path: Some("design".to_string()),
document_id: None,
title: "design".to_string(),
icon: Some("folder_open".to_string()),
sort_order: 0,
metadata_json: "{}".to_string(),
})
.expect("insert shortcut");
let updated = store
.upsert_sidebar_shortcut(UpsertSidebarShortcutInput {
id: None,
user_id: "alice".to_string(),
workspace_id: workspace.id.clone(),
root_uri: Some("file:///tmp/mnote".to_string()),
kind: "folder".to_string(),
source_kind: "local_folder".to_string(),
target_id: "local-dir:design".to_string(),
relative_path: Some("design".to_string()),
document_id: None,
title: "Design".to_string(),
icon: Some("folder_open".to_string()),
sort_order: 4,
metadata_json: "{\"scope\":\"filetree\"}".to_string(),
})
.expect("upsert shortcut");
assert_eq!(updated.id, first.id);
assert_eq!(updated.title, "Design");
assert_eq!(updated.root_uri.as_deref(), Some("file:///tmp/mnote"));
assert_eq!(updated.sort_order, 4);
assert_eq!(updated.revision, first.revision + 1);
let alice_shortcuts = store
.list_sidebar_shortcuts("alice", &workspace.id)
.expect("list alice shortcuts");
assert_eq!(alice_shortcuts.len(), 1);
assert_eq!(alice_shortcuts[0].target_id, "local-dir:design");
let alice_global_local_shortcuts = store
.list_sidebar_shortcuts_with_global_local("alice", "cloud-workspace")
.expect("list alice global local shortcuts");
assert_eq!(alice_global_local_shortcuts.len(), 1);
assert_eq!(alice_global_local_shortcuts[0].workspace_id, workspace.id);
let bob_shortcuts = store
.list_sidebar_shortcuts("bob", &workspace.id)
.expect("list bob shortcuts");
assert!(bob_shortcuts.is_empty());
store
.delete_sidebar_shortcut("alice", &updated.id)
.expect("delete shortcut");
let after_delete = store
.list_sidebar_shortcuts("alice", &workspace.id)
.expect("list deleted shortcuts");
assert!(after_delete.is_empty());
}
#[test]
fn user_ui_preferences_are_scoped_upserted_and_user_isolated() {
let store = store();
create_user(&store, "alice");
create_user(&store, "bob");
let first = store
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_mnt_Data1T_mnote".to_string()),
source_kind: Some("local_folder".to_string()),
scope_kind: "source_family".to_string(),
scope_id: "external_local_folder".to_string(),
key: "hideTitleHeader".to_string(),
value_json: "false".to_string(),
})
.expect("insert preference");
let updated = store
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_mnt_Data1T_mnote".to_string()),
source_kind: Some("local_folder".to_string()),
scope_kind: "source_family".to_string(),
scope_id: "external_local_folder".to_string(),
key: "hideTitleHeader".to_string(),
value_json: "true".to_string(),
})
.expect("upsert preference");
assert_eq!(updated.id, first.id);
assert_eq!(updated.value_json, "true");
assert_eq!(updated.revision, first.revision + 1);
let alice_preferences = store
.list_user_ui_preferences(
"alice",
Some("local:_mnt_Data1T_mnote"),
Some("local_folder"),
)
.expect("list alice preferences");
assert_eq!(alice_preferences.len(), 1);
assert_eq!(alice_preferences[0].scope_kind, "source_family");
assert_eq!(alice_preferences[0].scope_id, "external_local_folder");
let bob_preferences = store
.list_user_ui_preferences("bob", Some("local:_mnt_Data1T_mnote"), Some("local_folder"))
.expect("list bob preferences");
assert!(bob_preferences.is_empty());
}
#[test]
fn ai_policy_upsert_returns_workspace_policy_before_user_policy() {
let store = store();