feat(sidebar): reorder folder groups by drag and persist sort order
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user