feat(sidebar): add per-folder color swatch with picker and neutral conversation rail
- 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
This commit is contained in:
@@ -16,6 +16,7 @@ pub struct Model {
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub is_open: bool,
|
||||
pub sort_order: i32,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
60
src-tauri/src/db/migration/m20260424_000001_folder_color.rs
Normal file
60
src-tauri/src/db/migration/m20260424_000001_folder_color.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
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::Color)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("#22c55e"),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Backfill existing rows with a palette color chosen by (id - 1) mod N,
|
||||
// so existing workspaces get visually distinct swatches after migration.
|
||||
let conn = manager.get_connection();
|
||||
let sql = "UPDATE folder SET color = CASE ((id - 1) % 9) \
|
||||
WHEN 0 THEN '#ef4444' \
|
||||
WHEN 1 THEN '#f97316' \
|
||||
WHEN 2 THEN '#eab308' \
|
||||
WHEN 3 THEN '#84cc16' \
|
||||
WHEN 4 THEN '#22c55e' \
|
||||
WHEN 5 THEN '#06b6d4' \
|
||||
WHEN 6 THEN '#8b5cf6' \
|
||||
WHEN 7 THEN '#d946ef' \
|
||||
WHEN 8 THEN '#ec4899' \
|
||||
END";
|
||||
conn.execute(Statement::from_string(DbBackend::Sqlite, sql.to_string()))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Folder::Table)
|
||||
.drop_column(Folder::Color)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Folder {
|
||||
Table,
|
||||
Color,
|
||||
}
|
||||
@@ -12,6 +12,7 @@ mod m20260406_000001_agent_setting_model_provider;
|
||||
mod m20260420_000001_opened_tabs;
|
||||
mod m20260422_000001_folder_sort_order;
|
||||
mod m20260423_000001_drop_folder_parent_branch;
|
||||
mod m20260424_000001_folder_color;
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -30,6 +31,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260420_000001_opened_tabs::Migration),
|
||||
Box::new(m20260422_000001_folder_sort_order::Migration),
|
||||
Box::new(m20260423_000001_drop_folder_parent_branch::Migration),
|
||||
Box::new(m20260424_000001_folder_color::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,35 @@ 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,
|
||||
@@ -34,6 +63,7 @@ fn to_detail(m: folder::Model) -> FolderDetail {
|
||||
default_agent_type,
|
||||
last_opened_at: m.last_opened_at,
|
||||
sort_order: m.sort_order,
|
||||
color: m.color,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +111,7 @@ pub async fn add_folder(
|
||||
.unwrap_or(0);
|
||||
let active = folder::ActiveModel {
|
||||
id: NotSet,
|
||||
name: Set(name),
|
||||
name: Set(name.clone()),
|
||||
path: Set(path.to_string()),
|
||||
git_branch: Set(None),
|
||||
default_agent_type: Set(None),
|
||||
@@ -91,13 +121,42 @@ pub async fn add_folder(
|
||||
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()),
|
||||
};
|
||||
active.insert(conn).await?
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user