- Add `color` column to folder table with migration backfill and hash-based assignment on folder creation - Expose `update_folder_color` via Tauri command and `/update_folder_color` HTTP route - Render a color swatch before each folder name in the sidebar header; offer a 10-color palette (9 hues plus a theme-aware foreground sentinel) through the folder context menu - Show the folder header "new conversation" button only on hover - Drop the expanded-state tint on folder name and count badge; use a fixed neutral rail color for conversation items
270 lines
7.9 KiB
Rust
270 lines
7.9 KiB
Rust
use chrono::Utc;
|
|
use sea_orm::DatabaseConnection;
|
|
use sea_orm::{
|
|
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
|
|
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
|
|
};
|
|
|
|
use crate::db::entities::folder;
|
|
use crate::db::error::DbError;
|
|
use crate::models::agent::AgentType;
|
|
use crate::models::{FolderDetail, FolderHistoryEntry};
|
|
|
|
/// Palette kept in sync with the frontend swatch picker. Changes here must be
|
|
/// mirrored in `src/components/conversations/sidebar-conversation-list.tsx`.
|
|
/// `"foreground"` is a theme-aware sentinel the frontend resolves to
|
|
/// `var(--sidebar-foreground)`.
|
|
pub const FOLDER_COLOR_PALETTE: &[&str] = &[
|
|
"#ef4444",
|
|
"#f97316",
|
|
"#eab308",
|
|
"#84cc16",
|
|
"#22c55e",
|
|
"#06b6d4",
|
|
"#8b5cf6",
|
|
"#d946ef",
|
|
"#ec4899",
|
|
"foreground",
|
|
];
|
|
|
|
fn pick_folder_color(folder_id: i32, folder_name: &str) -> String {
|
|
let mut name_hash: u32 = 0;
|
|
for c in folder_name.chars() {
|
|
name_hash = name_hash.wrapping_mul(31).wrapping_add(c as u32);
|
|
}
|
|
let combined = (folder_id as u32)
|
|
.wrapping_mul(2654435761)
|
|
.wrapping_add(name_hash);
|
|
let idx = (combined as usize) % FOLDER_COLOR_PALETTE.len();
|
|
FOLDER_COLOR_PALETTE[idx].to_string()
|
|
}
|
|
|
|
fn to_entry(m: folder::Model) -> FolderHistoryEntry {
|
|
FolderHistoryEntry {
|
|
id: m.id,
|
|
path: m.path,
|
|
name: m.name,
|
|
last_opened_at: m.last_opened_at,
|
|
}
|
|
}
|
|
|
|
fn parse_agent_type(s: &Option<String>) -> Option<AgentType> {
|
|
s.as_deref()
|
|
.and_then(|v| serde_json::from_value(serde_json::Value::String(v.to_string())).ok())
|
|
}
|
|
|
|
fn to_detail(m: folder::Model) -> FolderDetail {
|
|
let default_agent_type = parse_agent_type(&m.default_agent_type);
|
|
FolderDetail {
|
|
id: m.id,
|
|
name: m.name,
|
|
path: m.path,
|
|
git_branch: m.git_branch,
|
|
default_agent_type,
|
|
last_opened_at: m.last_opened_at,
|
|
sort_order: m.sort_order,
|
|
color: m.color,
|
|
}
|
|
}
|
|
|
|
pub async fn get_folder_by_id(
|
|
conn: &DatabaseConnection,
|
|
folder_id: i32,
|
|
) -> Result<Option<FolderDetail>, DbError> {
|
|
let row = folder::Entity::find_by_id(folder_id)
|
|
.filter(folder::Column::DeletedAt.is_null())
|
|
.one(conn)
|
|
.await?;
|
|
|
|
Ok(row.map(to_detail))
|
|
}
|
|
|
|
pub async fn add_folder(
|
|
conn: &DatabaseConnection,
|
|
path: &str,
|
|
) -> Result<FolderHistoryEntry, DbError> {
|
|
let now = Utc::now();
|
|
let name = std::path::Path::new(path)
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| path.to_string());
|
|
|
|
let existing = folder::Entity::find()
|
|
.filter(folder::Column::Path.eq(path))
|
|
.one(conn)
|
|
.await?;
|
|
|
|
let model = if let Some(row) = existing {
|
|
let mut active = row.into_active_model();
|
|
active.name = Set(name);
|
|
active.last_opened_at = Set(now);
|
|
active.updated_at = Set(now);
|
|
active.deleted_at = Set(None);
|
|
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.clone()),
|
|
path: Set(path.to_string()),
|
|
git_branch: Set(None),
|
|
default_agent_type: Set(None),
|
|
last_opened_at: Set(now),
|
|
created_at: Set(now),
|
|
updated_at: Set(now),
|
|
deleted_at: Set(None),
|
|
is_open: Set(true),
|
|
sort_order: Set(max_order + 1),
|
|
// Temporary placeholder — we overwrite below with a hash derived
|
|
// from the final auto-assigned id so each new folder gets a
|
|
// deterministic, well-distributed palette color.
|
|
color: Set(FOLDER_COLOR_PALETTE[0].to_string()),
|
|
};
|
|
let inserted = active.insert(conn).await?;
|
|
let assigned = pick_folder_color(inserted.id, &name);
|
|
let mut active = inserted.into_active_model();
|
|
active.color = Set(assigned);
|
|
active.update(conn).await?
|
|
};
|
|
|
|
Ok(to_entry(model))
|
|
}
|
|
|
|
pub async fn update_folder_color(
|
|
conn: &DatabaseConnection,
|
|
folder_id: i32,
|
|
color: &str,
|
|
) -> Result<Option<FolderDetail>, DbError> {
|
|
let row = folder::Entity::find_by_id(folder_id)
|
|
.filter(folder::Column::DeletedAt.is_null())
|
|
.one(conn)
|
|
.await?;
|
|
|
|
let Some(row) = row else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let mut active = row.into_active_model();
|
|
active.color = Set(color.to_string());
|
|
active.updated_at = Set(Utc::now());
|
|
let updated = active.update(conn).await?;
|
|
Ok(Some(to_detail(updated)))
|
|
}
|
|
|
|
pub async fn list_folders(conn: &DatabaseConnection) -> Result<Vec<FolderHistoryEntry>, DbError> {
|
|
let rows = folder::Entity::find()
|
|
.filter(folder::Column::DeletedAt.is_null())
|
|
.order_by_desc(folder::Column::LastOpenedAt)
|
|
.all(conn)
|
|
.await?;
|
|
|
|
Ok(rows.into_iter().map(to_entry).collect())
|
|
}
|
|
|
|
pub async fn remove_folder(conn: &DatabaseConnection, path: &str) -> Result<(), DbError> {
|
|
let now = Utc::now();
|
|
let row = folder::Entity::find()
|
|
.filter(folder::Column::Path.eq(path))
|
|
.filter(folder::Column::DeletedAt.is_null())
|
|
.one(conn)
|
|
.await?;
|
|
|
|
if let Some(row) = row {
|
|
let mut active = row.into_active_model();
|
|
active.deleted_at = Set(Some(now));
|
|
active.updated_at = Set(now);
|
|
active.update(conn).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn set_folder_open(
|
|
conn: &DatabaseConnection,
|
|
folder_id: i32,
|
|
is_open: bool,
|
|
) -> Result<(), DbError> {
|
|
let row = folder::Entity::find_by_id(folder_id).one(conn).await?;
|
|
|
|
if let Some(row) = row {
|
|
let mut active = row.into_active_model();
|
|
active.is_open = Set(is_open);
|
|
active.updated_at = Set(Utc::now());
|
|
active.update(conn).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_open_folders(
|
|
conn: &DatabaseConnection,
|
|
) -> Result<Vec<FolderHistoryEntry>, DbError> {
|
|
let rows = folder::Entity::find()
|
|
.filter(folder::Column::DeletedAt.is_null())
|
|
.filter(folder::Column::IsOpen.eq(true))
|
|
.order_by_desc(folder::Column::LastOpenedAt)
|
|
.all(conn)
|
|
.await?;
|
|
|
|
Ok(rows.into_iter().map(to_entry).collect())
|
|
}
|
|
|
|
pub async fn list_open_folder_details(
|
|
conn: &DatabaseConnection,
|
|
) -> Result<Vec<FolderDetail>, DbError> {
|
|
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?;
|
|
|
|
Ok(rows.into_iter().map(to_detail).collect())
|
|
}
|
|
|
|
pub async fn list_all_folder_details(
|
|
conn: &DatabaseConnection,
|
|
) -> 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(())
|
|
}
|