diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 40dd2f3..179f074 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -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, +) -> 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) diff --git a/src-tauri/src/db/entities/folder.rs b/src-tauri/src/db/entities/folder.rs index 8eb3eba..f4fd9a9 100644 --- a/src-tauri/src/db/entities/folder.rs +++ b/src-tauri/src/db/entities/folder.rs @@ -16,6 +16,7 @@ pub struct Model { pub deleted_at: Option, pub is_open: bool, pub parent_branch: Option, + pub sort_order: i32, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src-tauri/src/db/migration/m20260422_000001_folder_sort_order.rs b/src-tauri/src/db/migration/m20260422_000001_folder_sort_order.rs new file mode 100644 index 0000000..bf6ee43 --- /dev/null +++ b/src-tauri/src/db/migration/m20260422_000001_folder_sort_order.rs @@ -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, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 95fbe8c..316d0fb 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -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), ] } } diff --git a/src-tauri/src/db/service/folder_service.rs b/src-tauri/src/db/service/folder_service.rs index e94e2d8..6023b40 100644 --- a/src-tauri/src/db/service/folder_service.rs +++ b/src-tauri/src/db/service/folder_service.rs @@ -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, 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, +) -> 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::>() + .join(" "); + let id_list = ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .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(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a2e7204..729fe21 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/models/folder.rs b/src-tauri/src/models/folder.rs index 920b7c6..ff03cd7 100644 --- a/src-tauri/src/models/folder.rs +++ b/src-tauri/src/models/folder.rs @@ -20,6 +20,7 @@ pub struct FolderDetail { pub parent_branch: Option, pub default_agent_type: Option, pub last_opened_at: DateTime, + pub sort_order: i32, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/web/handlers/folders.rs b/src-tauri/src/web/handlers/folders.rs index 834e15e..6bae498 100644 --- a/src-tauri/src/web/handlers/folders.rs +++ b/src-tauri/src/web/handlers/folders.rs @@ -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, +} + +pub async fn reorder_folders( + Extension(state): Extension>, + Json(params): Json, +) -> Result, 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 { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 3a9771a..ca2b9d8 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -106,6 +106,10 @@ pub fn build_router(state: Arc, 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), diff --git a/src/components/conversations/sidebar-conversation-card.tsx b/src/components/conversations/sidebar-conversation-card.tsx index 4ed105c..bc88c84 100644 --- a/src/components/conversations/sidebar-conversation-card.tsx +++ b/src/components/conversations/sidebar-conversation-card.tsx @@ -131,7 +131,10 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ <> -
+