feat(sidebar): reorder folder groups by drag and persist sort order

This commit is contained in:
xintaofei
2026-04-23 00:42:58 +08:00
parent 2dbdaa9c74
commit f1ce7179ea
25 changed files with 519 additions and 182 deletions

View File

@@ -589,6 +589,17 @@ pub async fn remove_folder_from_workspace(
.map_err(AppCommandError::from)
}
#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn reorder_folders(
db: tauri::State<'_, AppDatabase>,
ids: Vec<i32>,
) -> Result<(), AppCommandError> {
folder_service::reorder_folders(&db.conn, ids)
.await
.map_err(AppCommandError::from)
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn create_folder_directory(path: String) -> Result<(), AppCommandError> {
std::fs::create_dir_all(&path).map_err(AppCommandError::io)

View File

@@ -16,6 +16,7 @@ pub struct Model {
pub deleted_at: Option<DateTimeUtc>,
pub is_open: bool,
pub parent_branch: Option<String>,
pub sort_order: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -0,0 +1,72 @@
use sea_orm_migration::prelude::*;
use sea_orm_migration::sea_orm::{ConnectionTrait, DbBackend, Statement};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Folder::Table)
.add_column(
ColumnDef::new(Folder::SortOrder)
.integer()
.not_null()
.default(0),
)
.to_owned(),
)
.await?;
// Backfill sort_order by current last_opened_at DESC so existing users
// see the same order after migration. We use a correlated subquery that
// works on SQLite without ROW_NUMBER/window functions.
let conn = manager.get_connection();
let sql = "UPDATE folder SET sort_order = (\
SELECT COUNT(*) FROM folder AS inner_f \
WHERE inner_f.last_opened_at > folder.last_opened_at \
OR (inner_f.last_opened_at = folder.last_opened_at AND inner_f.id < folder.id) \
) + 1";
conn.execute(Statement::from_string(DbBackend::Sqlite, sql.to_string()))
.await?;
manager
.create_index(
Index::create()
.name("idx_folder_sort_order")
.table(Folder::Table)
.col(Folder::SortOrder)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_index(
Index::drop()
.name("idx_folder_sort_order")
.table(Folder::Table)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Folder::Table)
.drop_column(Folder::SortOrder)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Folder {
Table,
SortOrder,
}

View File

@@ -10,6 +10,7 @@ mod m20260401_000001_chat_channel_sender_context;
mod m20260404_000001_model_provider;
mod m20260406_000001_agent_setting_model_provider;
mod m20260420_000001_opened_tabs;
mod m20260422_000001_folder_sort_order;
pub struct Migrator;
#[async_trait::async_trait]
@@ -26,6 +27,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260404_000001_model_provider::Migration),
Box::new(m20260406_000001_agent_setting_model_provider::Migration),
Box::new(m20260420_000001_opened_tabs::Migration),
Box::new(m20260422_000001_folder_sort_order::Migration),
]
}
}

View File

@@ -1,8 +1,8 @@
use chrono::Utc;
use sea_orm::DatabaseConnection;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter,
QueryOrder, Set,
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
};
use crate::db::entities::folder;
@@ -34,6 +34,7 @@ fn to_detail(m: folder::Model) -> FolderDetail {
parent_branch: m.parent_branch,
default_agent_type,
last_opened_at: m.last_opened_at,
sort_order: m.sort_order,
}
}
@@ -73,6 +74,12 @@ pub async fn add_folder(
active.is_open = Set(true);
active.update(conn).await?
} else {
let max_order = folder::Entity::find()
.order_by_desc(folder::Column::SortOrder)
.one(conn)
.await?
.map(|m| m.sort_order)
.unwrap_or(0);
let active = folder::ActiveModel {
id: NotSet,
name: Set(name),
@@ -85,6 +92,7 @@ pub async fn add_folder(
updated_at: Set(now),
deleted_at: Set(None),
is_open: Set(true),
sort_order: Set(max_order + 1),
};
active.insert(conn).await?
};
@@ -170,6 +178,7 @@ pub async fn list_open_folder_details(
let rows = folder::Entity::find()
.filter(folder::Column::DeletedAt.is_null())
.filter(folder::Column::IsOpen.eq(true))
.order_by_asc(folder::Column::SortOrder)
.order_by_desc(folder::Column::LastOpenedAt)
.all(conn)
.await?;
@@ -182,9 +191,41 @@ pub async fn list_all_folder_details(
) -> Result<Vec<FolderDetail>, DbError> {
let rows = folder::Entity::find()
.filter(folder::Column::DeletedAt.is_null())
.order_by_asc(folder::Column::SortOrder)
.order_by_desc(folder::Column::LastOpenedAt)
.all(conn)
.await?;
Ok(rows.into_iter().map(to_detail).collect())
}
pub async fn reorder_folders(
conn: &DatabaseConnection,
ids: Vec<i32>,
) -> Result<(), DbError> {
if ids.is_empty() {
return Ok(());
}
let now = Utc::now();
let now_str = now.format("%Y-%m-%d %H:%M:%S %:z").to_string();
let case_expr = ids
.iter()
.enumerate()
.map(|(idx, id)| format!("WHEN {} THEN {}", id, idx + 1))
.collect::<Vec<_>>()
.join(" ");
let id_list = ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"UPDATE folder SET sort_order = CASE id {case_expr} END, updated_at = '{now_str}' WHERE id IN ({id_list})"
);
conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
.await?;
Ok(())
}

View File

@@ -223,6 +223,7 @@ mod tauri_app {
folders::open_folder,
folders::open_folder_by_id,
folders::remove_folder_from_workspace,
folders::reorder_folders,
folders::add_folder_to_history,
folders::set_folder_parent_branch,
folders::remove_folder_from_history,

View File

@@ -20,6 +20,7 @@ pub struct FolderDetail {
pub parent_branch: Option<String>,
pub default_agent_type: Option<AgentType>,
pub last_opened_at: DateTime<Utc>,
pub sort_order: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -123,6 +123,23 @@ pub async fn remove_folder_from_workspace(
Ok(Json(()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReorderFoldersParams {
pub ids: Vec<i32>,
}
pub async fn reorder_folders(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<ReorderFoldersParams>,
) -> Result<Json<()>, AppCommandError> {
let db = &state.db;
folder_service::reorder_folders(&db.conn, params.ids)
.await
.map_err(AppCommandError::from)?;
Ok(Json(()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PathParams {

View File

@@ -106,6 +106,10 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
"/remove_folder_from_workspace",
post(handlers::folders::remove_folder_from_workspace),
)
.route(
"/reorder_folders",
post(handlers::folders::reorder_folders),
)
.route(
"/add_folder_to_history",
post(handlers::folders::add_folder_to_history),