Improve local filetree view state and sidebar performance
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user