Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "agent_setting")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub agent_type: String,
pub registry_id: String,
pub enabled: bool,
pub sort_order: i32,
pub installed_version: Option<String>,
pub env_json: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,19 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "app_metadata")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub key: String,
pub value: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub deleted_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,53 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
#[serde(rename_all = "snake_case")]
pub enum ConversationStatus {
#[sea_orm(string_value = "in_progress")]
InProgress,
#[sea_orm(string_value = "pending_review")]
PendingReview,
#[sea_orm(string_value = "completed")]
Completed,
#[sea_orm(string_value = "cancelled")]
Cancelled,
}
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "conversation")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub folder_id: i32,
pub title: Option<String>,
pub agent_type: String,
pub status: ConversationStatus,
pub model: Option<String>,
pub git_branch: Option<String>,
pub external_id: Option<String>,
pub parent_id: Option<i32>,
pub message_count: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub deleted_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::folder::Entity",
from = "Column::FolderId",
to = "super::folder::Column::Id"
)]
Folder,
}
impl Related<super::folder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Folder.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,51 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "folder")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
#[sea_orm(unique)]
pub path: String,
pub git_branch: Option<String>,
pub default_agent_type: Option<String>,
pub last_opened_at: DateTimeUtc,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub deleted_at: Option<DateTimeUtc>,
pub is_open: bool,
pub parent_branch: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::conversation::Entity")]
Conversations,
#[sea_orm(has_many = "super::folder_opened_conversation::Entity")]
OpenedConversations,
#[sea_orm(has_many = "super::folder_command::Entity")]
FolderCommands,
}
impl Related<super::conversation::Entity> for Entity {
fn to() -> RelationDef {
Relation::Conversations.def()
}
}
impl Related<super::folder_opened_conversation::Entity> for Entity {
fn to() -> RelationDef {
Relation::OpenedConversations.def()
}
}
impl Related<super::folder_command::Entity> for Entity {
fn to() -> RelationDef {
Relation::FolderCommands.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,32 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "folder_command")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub folder_id: i32,
pub name: String,
pub command: String,
pub sort_order: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::folder::Entity",
from = "Column::FolderId",
to = "super::folder::Column::Id"
)]
Folder,
}
impl Related<super::folder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Folder.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,47 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "folder_opened_conversation")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub folder_id: i32,
pub conversation_id: i32,
pub position: i32,
pub is_active: bool,
pub is_pinned: bool,
pub agent_type: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::folder::Entity",
from = "Column::FolderId",
to = "super::folder::Column::Id"
)]
Folder,
#[sea_orm(
belongs_to = "super::conversation::Entity",
from = "Column::ConversationId",
to = "super::conversation::Column::Id"
)]
Conversation,
}
impl Related<super::folder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Folder.def()
}
}
impl Related<super::conversation::Entity> for Entity {
fn to() -> RelationDef {
Relation::Conversation.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,7 @@
pub mod agent_setting;
pub mod app_metadata;
pub mod conversation;
pub mod folder;
pub mod folder_command;
pub mod folder_opened_conversation;
pub mod prelude;

View File

@@ -0,0 +1,8 @@
#![allow(unused_imports)]
pub use super::agent_setting::Entity as AgentSetting;
pub use super::app_metadata::Entity as AppMetadata;
pub use super::conversation::Entity as Conversation;
pub use super::folder::Entity as Folder;
pub use super::folder_command::Entity as FolderCommand;
pub use super::folder_opened_conversation::Entity as FolderOpenedConversation;

23
src-tauri/src/db/error.rs Normal file
View File

@@ -0,0 +1,23 @@
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("database error: {0}")]
Database(#[from] sea_orm::DbErr),
#[error("migration error: {0}")]
Migration(String),
#[allow(dead_code)]
#[error("database not initialized")]
NotInitialized,
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
impl Serialize for DbError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}

View File

@@ -0,0 +1,378 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 1. app_metadata table
manager
.create_table(
Table::create()
.table(AppMetadata::Table)
.if_not_exists()
.col(
ColumnDef::new(AppMetadata::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(AppMetadata::Key)
.string()
.not_null()
.unique_key(),
)
.col(ColumnDef::new(AppMetadata::Value).string().not_null())
.col(
ColumnDef::new(AppMetadata::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(AppMetadata::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(AppMetadata::DeletedAt)
.timestamp_with_time_zone()
.null(),
)
.to_owned(),
)
.await?;
// 2. folder table
manager
.create_table(
Table::create()
.table(Folder::Table)
.if_not_exists()
.col(
ColumnDef::new(Folder::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Folder::Name).string().not_null())
.col(
ColumnDef::new(Folder::Path)
.string()
.not_null()
.unique_key(),
)
.col(ColumnDef::new(Folder::GitBranch).string().null())
.col(ColumnDef::new(Folder::DefaultAgentType).string().null())
.col(
ColumnDef::new(Folder::LastOpenedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Folder::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Folder::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Folder::DeletedAt)
.timestamp_with_time_zone()
.null(),
)
.to_owned(),
)
.await?;
// 3. conversation table
manager
.create_table(
Table::create()
.table(Conversation::Table)
.if_not_exists()
.col(
ColumnDef::new(Conversation::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Conversation::FolderId).integer().not_null())
.col(ColumnDef::new(Conversation::Title).string().null())
.col(ColumnDef::new(Conversation::AgentType).string().not_null())
.col(
ColumnDef::new(Conversation::Status)
.string()
.not_null()
.default("in_progress"),
)
.col(ColumnDef::new(Conversation::Model).string().null())
.col(ColumnDef::new(Conversation::GitBranch).string().null())
.col(ColumnDef::new(Conversation::ExternalId).string().null())
.col(ColumnDef::new(Conversation::ParentId).integer().null())
.col(
ColumnDef::new(Conversation::MessageCount)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(Conversation::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Conversation::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Conversation::DeletedAt)
.timestamp_with_time_zone()
.null(),
)
.foreign_key(
ForeignKey::create()
.name("fk_conversation_folder")
.from(Conversation::Table, Conversation::FolderId)
.to(Folder::Table, Folder::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
// 4. folder_opened_conversation table
manager
.create_table(
Table::create()
.table(FolderOpenedConversation::Table)
.if_not_exists()
.col(
ColumnDef::new(FolderOpenedConversation::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(FolderOpenedConversation::FolderId)
.integer()
.not_null(),
)
.col(
ColumnDef::new(FolderOpenedConversation::ConversationId)
.integer()
.not_null(),
)
.col(
ColumnDef::new(FolderOpenedConversation::Position)
.integer()
.not_null(),
)
.col(
ColumnDef::new(FolderOpenedConversation::IsActive)
.boolean()
.not_null()
.default(false),
)
.col(
ColumnDef::new(FolderOpenedConversation::IsPinned)
.boolean()
.not_null()
.default(true),
)
.col(
ColumnDef::new(FolderOpenedConversation::AgentType)
.string()
.not_null(),
)
.col(
ColumnDef::new(FolderOpenedConversation::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(FolderOpenedConversation::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.foreign_key(
ForeignKey::create()
.name("fk_foc_folder")
.from(
FolderOpenedConversation::Table,
FolderOpenedConversation::FolderId,
)
.to(Folder::Table, Folder::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_foc_conversation")
.from(
FolderOpenedConversation::Table,
FolderOpenedConversation::ConversationId,
)
.to(Conversation::Table, Conversation::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
// 6. indexes
manager
.create_index(
Index::create()
.name("idx_folder_deleted_last_opened")
.table(Folder::Table)
.col(Folder::DeletedAt)
.col(Folder::LastOpenedAt)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_conversation_folder_id")
.table(Conversation::Table)
.col(Conversation::FolderId)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_conversation_deleted_created")
.table(Conversation::Table)
.col(Conversation::DeletedAt)
.col(Conversation::CreatedAt)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_conversation_external_agent")
.table(Conversation::Table)
.col(Conversation::ExternalId)
.col(Conversation::AgentType)
.unique()
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_foc_folder_position")
.table(FolderOpenedConversation::Table)
.col(FolderOpenedConversation::FolderId)
.col(FolderOpenedConversation::Position)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_foc_folder_conversation")
.table(FolderOpenedConversation::Table)
.col(FolderOpenedConversation::FolderId)
.col(FolderOpenedConversation::ConversationId)
.unique()
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(
Table::drop()
.table(FolderOpenedConversation::Table)
.to_owned(),
)
.await?;
manager
.drop_table(Table::drop().table(Conversation::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Folder::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(AppMetadata::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum AppMetadata {
Table,
Id,
Key,
Value,
CreatedAt,
UpdatedAt,
DeletedAt,
}
#[derive(DeriveIden)]
enum Folder {
Table,
Id,
Name,
Path,
GitBranch,
DefaultAgentType,
LastOpenedAt,
CreatedAt,
UpdatedAt,
DeletedAt,
}
#[derive(DeriveIden)]
enum Conversation {
Table,
Id,
FolderId,
Title,
AgentType,
Status,
Model,
GitBranch,
ExternalId,
ParentId,
MessageCount,
CreatedAt,
UpdatedAt,
DeletedAt,
}
#[derive(DeriveIden)]
enum FolderOpenedConversation {
Table,
Id,
FolderId,
ConversationId,
Position,
IsActive,
IsPinned,
AgentType,
CreatedAt,
UpdatedAt,
}

View File

@@ -0,0 +1,87 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(FolderCommand::Table)
.if_not_exists()
.col(
ColumnDef::new(FolderCommand::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(FolderCommand::FolderId).integer().not_null())
.col(ColumnDef::new(FolderCommand::Name).string().not_null())
.col(ColumnDef::new(FolderCommand::Command).string().not_null())
.col(
ColumnDef::new(FolderCommand::SortOrder)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(FolderCommand::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(FolderCommand::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.foreign_key(
ForeignKey::create()
.name("fk_folder_command_folder")
.from(FolderCommand::Table, FolderCommand::FolderId)
.to(Folder::Table, Folder::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_folder_command_folder_id")
.table(FolderCommand::Table)
.col(FolderCommand::FolderId)
.col(FolderCommand::SortOrder)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(FolderCommand::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum FolderCommand {
Table,
Id,
FolderId,
Name,
Command,
SortOrder,
CreatedAt,
UpdatedAt,
}
#[derive(DeriveIden)]
enum Folder {
Table,
Id,
}

View File

@@ -0,0 +1,40 @@
use sea_orm_migration::prelude::*;
#[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::IsOpen)
.boolean()
.not_null()
.default(false),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Folder::Table)
.drop_column(Folder::IsOpen)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Folder {
Table,
IsOpen,
}

View File

@@ -0,0 +1,90 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(AgentSetting::Table)
.if_not_exists()
.col(
ColumnDef::new(AgentSetting::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(AgentSetting::AgentType)
.string()
.not_null()
.unique_key(),
)
.col(ColumnDef::new(AgentSetting::RegistryId).string().not_null())
.col(
ColumnDef::new(AgentSetting::Enabled)
.boolean()
.not_null()
.default(true),
)
.col(
ColumnDef::new(AgentSetting::SortOrder)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(AgentSetting::InstalledVersion)
.string()
.null(),
)
.col(ColumnDef::new(AgentSetting::EnvJson).text().null())
.col(
ColumnDef::new(AgentSetting::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(AgentSetting::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_agent_setting_sort_order")
.table(AgentSetting::Table)
.col(AgentSetting::SortOrder)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(AgentSetting::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum AgentSetting {
Table,
Id,
AgentType,
RegistryId,
Enabled,
SortOrder,
InstalledVersion,
EnvJson,
CreatedAt,
UpdatedAt,
}

View File

@@ -0,0 +1,35 @@
use sea_orm_migration::prelude::*;
#[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::ParentBranch).string().null())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Folder::Table)
.drop_column(Folder::ParentBranch)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Folder {
Table,
ParentBranch,
}

View File

@@ -0,0 +1,21 @@
use sea_orm_migration::prelude::*;
mod m20260211_000001_init;
mod m20260219_000001_folder_command;
mod m20260221_000001_folder_is_open;
mod m20260226_000001_agent_setting;
mod m20260227_000001_folder_parent_branch;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20260211_000001_init::Migration),
Box::new(m20260219_000001_folder_command::Migration),
Box::new(m20260221_000001_folder_is_open::Migration),
Box::new(m20260226_000001_agent_setting::Migration),
Box::new(m20260227_000001_folder_parent_branch::Migration),
]
}
}

78
src-tauri/src/db/mod.rs Normal file
View File

@@ -0,0 +1,78 @@
pub mod entities;
pub mod error;
pub mod migration;
pub mod service;
use std::path::Path;
use std::time::Duration;
use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement,
};
use sea_orm_migration::MigratorTrait;
use error::DbError;
use migration::Migrator;
pub struct AppDatabase {
#[allow(dead_code)]
pub conn: DatabaseConnection,
}
pub async fn init_database(
app_data_dir: impl AsRef<Path>,
app_version: &str,
) -> Result<AppDatabase, DbError> {
let app_data_dir = app_data_dir.as_ref();
std::fs::create_dir_all(app_data_dir)?;
let db_path = app_data_dir.join("codeg.db");
let db_url = format!(
"sqlite:{}?mode=rwc",
urlencoding::encode(&db_path.to_string_lossy())
);
let mut opts = ConnectOptions::new(db_url);
opts.max_connections(5)
.min_connections(1)
.connect_timeout(Duration::from_secs(10))
.idle_timeout(Duration::from_secs(300))
.sqlx_logging(false);
let conn = Database::connect(opts).await?;
// SQLite performance and reliability pragmas
conn.execute(Statement::from_string(
DbBackend::Sqlite,
"PRAGMA journal_mode=WAL;".to_owned(),
))
.await?;
conn.execute(Statement::from_string(
DbBackend::Sqlite,
"PRAGMA busy_timeout=5000;".to_owned(),
))
.await?;
conn.execute(Statement::from_string(
DbBackend::Sqlite,
"PRAGMA synchronous=NORMAL;".to_owned(),
))
.await?;
conn.execute(Statement::from_string(
DbBackend::Sqlite,
"PRAGMA foreign_keys=ON;".to_owned(),
))
.await?;
conn.execute(Statement::from_string(
DbBackend::Sqlite,
"PRAGMA cache_size=-8000;".to_owned(),
))
.await?;
Migrator::up(&conn, None)
.await
.map_err(|e| DbError::Migration(e.to_string()))?;
service::app_metadata_service::update_app_version(&conn, app_version).await?;
Ok(AppDatabase { conn })
}

View File

@@ -0,0 +1,207 @@
use std::collections::HashMap;
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::agent_setting;
use crate::db::error::DbError;
use crate::models::agent::AgentType;
#[derive(Debug, Clone)]
pub struct AgentDefaultInput {
pub agent_type: AgentType,
pub registry_id: String,
pub default_sort_order: i32,
}
#[derive(Debug, Clone)]
pub struct AgentSettingsUpdate {
pub enabled: bool,
pub env_json: Option<String>,
}
fn default_enabled(agent_type: AgentType) -> bool {
matches!(
agent_type,
AgentType::ClaudeCode
| AgentType::Codex
| AgentType::Gemini
| AgentType::OpenCode
| AgentType::OpenClaw
)
}
pub async fn ensure_defaults(
conn: &DatabaseConnection,
defaults: &[AgentDefaultInput],
) -> Result<(), DbError> {
for default in defaults {
let agent_type = serde_json::to_string(&default.agent_type)
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
let existing = agent_setting::Entity::find()
.filter(agent_setting::Column::AgentType.eq(agent_type.clone()))
.one(conn)
.await?;
if let Some(model) = existing {
if model.registry_id != default.registry_id {
let mut active = model.into_active_model();
active.registry_id = Set(default.registry_id.clone());
active.updated_at = Set(Utc::now());
active.update(conn).await?;
}
continue;
}
let now = Utc::now();
let active = agent_setting::ActiveModel {
id: NotSet,
agent_type: Set(agent_type),
registry_id: Set(default.registry_id.clone()),
enabled: Set(default_enabled(default.agent_type)),
sort_order: Set(default.default_sort_order),
installed_version: Set(None),
env_json: Set(None),
created_at: Set(now),
updated_at: Set(now),
};
match active.insert(conn).await {
Ok(_) => {}
Err(e) if e.to_string().contains("UNIQUE constraint failed") => {
// Another concurrent call already inserted this row — safe to ignore.
continue;
}
Err(e) => return Err(e.into()),
}
}
Ok(())
}
pub async fn list(conn: &DatabaseConnection) -> Result<Vec<agent_setting::Model>, DbError> {
let rows = agent_setting::Entity::find()
.order_by_asc(agent_setting::Column::SortOrder)
.all(conn)
.await?;
Ok(rows)
}
pub async fn list_map_by_agent_type(
conn: &DatabaseConnection,
) -> Result<HashMap<AgentType, agent_setting::Model>, DbError> {
let rows = list(conn).await?;
let mut map = HashMap::new();
for row in rows {
if let Ok(agent_type) = serde_json::from_str::<AgentType>(&row.agent_type) {
map.insert(agent_type, row);
}
}
Ok(map)
}
pub async fn get_by_agent_type(
conn: &DatabaseConnection,
agent_type: AgentType,
) -> Result<Option<agent_setting::Model>, DbError> {
let agent_type_str = serde_json::to_string(&agent_type)
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
let model = agent_setting::Entity::find()
.filter(agent_setting::Column::AgentType.eq(agent_type_str))
.one(conn)
.await?;
Ok(model)
}
pub async fn update(
conn: &DatabaseConnection,
agent_type: AgentType,
patch: AgentSettingsUpdate,
) -> Result<(), DbError> {
let agent_type_str = serde_json::to_string(&agent_type)
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
let model = agent_setting::Entity::find()
.filter(agent_setting::Column::AgentType.eq(agent_type_str.clone()))
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("agent setting not found: {agent_type_str}")))?;
let mut active = model.into_active_model();
active.enabled = Set(patch.enabled);
active.env_json = Set(patch.env_json);
active.updated_at = Set(Utc::now());
active.update(conn).await?;
Ok(())
}
pub async fn set_installed_version(
conn: &DatabaseConnection,
agent_type: AgentType,
installed_version: Option<String>,
) -> Result<(), DbError> {
let agent_type_str = serde_json::to_string(&agent_type)
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
if let Some(model) = agent_setting::Entity::find()
.filter(agent_setting::Column::AgentType.eq(agent_type_str))
.one(conn)
.await?
{
let mut active = model.into_active_model();
active.installed_version = Set(installed_version);
active.updated_at = Set(Utc::now());
active.update(conn).await?;
}
Ok(())
}
pub async fn reorder(conn: &DatabaseConnection, agent_types: &[AgentType]) -> Result<(), DbError> {
if agent_types.is_empty() {
return Ok(());
}
match reorder_once(conn, agent_types).await {
Ok(()) => Ok(()),
Err(err) if is_sqlite_full_error(&err) => {
// Try truncating WAL once to reclaim space and retry.
conn.execute(Statement::from_string(
DbBackend::Sqlite,
"PRAGMA wal_checkpoint(TRUNCATE);".to_owned(),
))
.await?;
reorder_once(conn, agent_types).await
}
Err(err) => Err(err),
}
}
async fn reorder_once(conn: &DatabaseConnection, agent_types: &[AgentType]) -> Result<(), DbError> {
let now = Utc::now();
for (index, agent_type) in agent_types.iter().enumerate() {
let agent_type_str = serde_json::to_string(agent_type)
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
if let Some(model) = agent_setting::Entity::find()
.filter(agent_setting::Column::AgentType.eq(agent_type_str))
.one(conn)
.await?
{
// Skip unchanged rows to reduce write pressure when repeatedly dragging.
if model.sort_order == index as i32 {
continue;
}
let mut active = model.into_active_model();
active.sort_order = Set(index as i32);
active.updated_at = Set(now.clone());
active.update(conn).await?;
}
}
Ok(())
}
fn is_sqlite_full_error(err: &DbError) -> bool {
let message = err.to_string();
message.contains("database or disk is full") || message.contains("(code: 13)")
}

View File

@@ -0,0 +1,84 @@
use chrono::Utc;
use sea_orm::sea_query::OnConflict;
use sea_orm::DatabaseConnection;
use sea_orm::{ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set};
use crate::db::entities::app_metadata;
use crate::db::error::DbError;
pub async fn upsert_value(
conn: &DatabaseConnection,
key: &str,
value: &str,
) -> Result<(), DbError> {
let now = Utc::now();
app_metadata::Entity::insert(app_metadata::ActiveModel {
id: NotSet,
key: Set(key.to_string()),
value: Set(value.to_string()),
created_at: Set(now),
updated_at: Set(now),
deleted_at: NotSet,
})
.on_conflict(
OnConflict::column(app_metadata::Column::Key)
.update_columns([app_metadata::Column::Value, app_metadata::Column::UpdatedAt])
.to_owned(),
)
.exec(conn)
.await?;
Ok(())
}
pub async fn get_value(conn: &DatabaseConnection, key: &str) -> Result<Option<String>, DbError> {
let model = app_metadata::Entity::find()
.filter(app_metadata::Column::Key.eq(key))
.filter(app_metadata::Column::DeletedAt.is_null())
.one(conn)
.await?;
Ok(model.map(|m| m.value))
}
pub async fn update_app_version(
conn: &DatabaseConnection,
app_version: &str,
) -> Result<(), DbError> {
let now = Utc::now();
app_metadata::Entity::insert(app_metadata::ActiveModel {
id: NotSet,
key: Set("app_version".to_string()),
value: Set(app_version.to_string()),
created_at: Set(now),
updated_at: Set(now),
deleted_at: NotSet,
})
.on_conflict(
OnConflict::column(app_metadata::Column::Key)
.update_columns([app_metadata::Column::Value, app_metadata::Column::UpdatedAt])
.to_owned(),
)
.exec(conn)
.await?;
app_metadata::Entity::insert(app_metadata::ActiveModel {
id: NotSet,
key: Set("db_initialized_at".to_string()),
value: Set(now.to_rfc3339()),
created_at: Set(now),
updated_at: Set(now),
deleted_at: NotSet,
})
.on_conflict(
OnConflict::column(app_metadata::Column::Key)
.do_nothing()
.to_owned(),
)
.do_nothing()
.exec(conn)
.await?;
Ok(())
}

View File

@@ -0,0 +1,187 @@
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
QueryFilter, QueryOrder, Set,
};
use crate::db::entities::conversation;
use crate::db::error::DbError;
use crate::models::{AgentType, DbConversationSummary};
pub async fn create(
conn: &DatabaseConnection,
folder_id: i32,
agent_type: AgentType,
title: Option<String>,
git_branch: Option<String>,
) -> Result<conversation::Model, DbError> {
let at_str = serde_json::to_value(agent_type)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
let now = Utc::now();
let model = conversation::ActiveModel {
id: NotSet,
folder_id: Set(folder_id),
title: Set(title),
agent_type: Set(at_str),
status: Set(conversation::ConversationStatus::InProgress),
model: Set(None),
git_branch: Set(git_branch),
external_id: Set(None),
parent_id: Set(None),
message_count: Set(0),
created_at: Set(now),
updated_at: Set(now),
deleted_at: Set(None),
};
Ok(model.insert(conn).await?)
}
pub async fn update_status(
conn: &DatabaseConnection,
conversation_id: i32,
status: conversation::ConversationStatus,
) -> Result<(), DbError> {
let conv = conversation::Entity::find_by_id(conversation_id)
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
let mut active: conversation::ActiveModel = conv.into();
active.status = Set(status);
active.updated_at = Set(Utc::now());
active.update(conn).await?;
Ok(())
}
pub async fn update_title(
conn: &DatabaseConnection,
conversation_id: i32,
title: String,
) -> Result<(), DbError> {
let conv = conversation::Entity::find_by_id(conversation_id)
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
let mut active: conversation::ActiveModel = conv.into();
active.title = Set(Some(title));
active.updated_at = Set(Utc::now());
active.update(conn).await?;
Ok(())
}
pub async fn update_external_id(
conn: &DatabaseConnection,
conversation_id: i32,
external_id: String,
) -> Result<(), DbError> {
let conv = conversation::Entity::find_by_id(conversation_id)
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
let mut active: conversation::ActiveModel = conv.into();
active.external_id = Set(Some(external_id));
active.updated_at = Set(Utc::now());
active.update(conn).await?;
Ok(())
}
pub async fn soft_delete(conn: &DatabaseConnection, conversation_id: i32) -> Result<(), DbError> {
let conv = conversation::Entity::find_by_id(conversation_id)
.filter(conversation::Column::DeletedAt.is_null())
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
let mut active: conversation::ActiveModel = conv.into();
active.deleted_at = Set(Some(Utc::now()));
active.update(conn).await?;
Ok(())
}
fn parse_agent_type(s: &str) -> AgentType {
serde_json::from_value(serde_json::Value::String(s.to_string()))
.unwrap_or(AgentType::ClaudeCode)
}
fn conv_to_summary(r: conversation::Model) -> DbConversationSummary {
let status = serde_json::to_value(&r.status)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| format!("{:?}", r.status));
DbConversationSummary {
id: r.id,
folder_id: r.folder_id,
title: r.title,
agent_type: parse_agent_type(&r.agent_type),
status,
model: r.model,
git_branch: r.git_branch,
external_id: r.external_id,
message_count: r.message_count as u32,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
pub async fn get_by_id(
conn: &DatabaseConnection,
conversation_id: i32,
) -> Result<DbConversationSummary, DbError> {
let conv = conversation::Entity::find_by_id(conversation_id)
.filter(conversation::Column::DeletedAt.is_null())
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
Ok(conv_to_summary(conv))
}
pub async fn list_by_folder(
conn: &DatabaseConnection,
folder_id: i32,
agent_type: Option<AgentType>,
search: Option<String>,
sort_by: Option<String>,
status: Option<String>,
) -> Result<Vec<DbConversationSummary>, DbError> {
let mut query = conversation::Entity::find()
.filter(conversation::Column::FolderId.eq(folder_id))
.filter(conversation::Column::DeletedAt.is_null());
// Filter by agent_type
if let Some(ref at) = agent_type {
let at_str = serde_json::to_value(at)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
query = query.filter(conversation::Column::AgentType.eq(at_str));
}
// Search by title
if let Some(ref s) = search {
if !s.is_empty() {
query = query.filter(conversation::Column::Title.contains(s));
}
}
// Filter by status
if let Some(ref st) = status {
if let Ok(status_enum) = serde_json::from_value::<conversation::ConversationStatus>(
serde_json::Value::String(st.clone()),
) {
query = query.filter(conversation::Column::Status.eq(status_enum));
}
}
// Sort
query = match sort_by.as_deref() {
Some("oldest") => query.order_by_asc(conversation::Column::CreatedAt),
_ => query.order_by_desc(conversation::Column::CreatedAt),
};
let rows = query.all(conn).await?;
let summaries: Vec<DbConversationSummary> = rows.into_iter().map(conv_to_summary).collect();
Ok(summaries)
}

View File

@@ -0,0 +1,171 @@
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_command;
use crate::db::error::DbError;
use crate::models::FolderCommandInfo;
fn to_info(m: folder_command::Model) -> FolderCommandInfo {
FolderCommandInfo {
id: m.id,
folder_id: m.folder_id,
name: m.name,
command: m.command,
sort_order: m.sort_order,
created_at: m.created_at,
updated_at: m.updated_at,
}
}
pub async fn list_by_folder(
conn: &DatabaseConnection,
folder_id: i32,
) -> Result<Vec<FolderCommandInfo>, DbError> {
let rows = folder_command::Entity::find()
.filter(folder_command::Column::FolderId.eq(folder_id))
.order_by_asc(folder_command::Column::SortOrder)
.all(conn)
.await?;
Ok(rows.into_iter().map(to_info).collect())
}
pub async fn create(
conn: &DatabaseConnection,
folder_id: i32,
name: &str,
command: &str,
) -> Result<FolderCommandInfo, DbError> {
let now = Utc::now();
// Get next sort_order
let max_order = folder_command::Entity::find()
.filter(folder_command::Column::FolderId.eq(folder_id))
.order_by_desc(folder_command::Column::SortOrder)
.one(conn)
.await?
.map(|m| m.sort_order)
.unwrap_or(-1);
let active = folder_command::ActiveModel {
id: NotSet,
folder_id: Set(folder_id),
name: Set(name.to_string()),
command: Set(command.to_string()),
sort_order: Set(max_order + 1),
created_at: Set(now),
updated_at: Set(now),
};
let model = active.insert(conn).await?;
Ok(to_info(model))
}
pub async fn create_many(
conn: &DatabaseConnection,
folder_id: i32,
items: &[(String, String)],
) -> Result<(), DbError> {
if items.is_empty() {
return Ok(());
}
let now = Utc::now();
let max_order = folder_command::Entity::find()
.filter(folder_command::Column::FolderId.eq(folder_id))
.order_by_desc(folder_command::Column::SortOrder)
.one(conn)
.await?
.map(|m| m.sort_order)
.unwrap_or(-1);
let active_models = items
.iter()
.enumerate()
.map(|(idx, (name, command))| folder_command::ActiveModel {
id: NotSet,
folder_id: Set(folder_id),
name: Set(name.clone()),
command: Set(command.clone()),
sort_order: Set(max_order + idx as i32 + 1),
created_at: Set(now),
updated_at: Set(now),
})
.collect::<Vec<_>>();
folder_command::Entity::insert_many(active_models)
.exec(conn)
.await?;
Ok(())
}
pub async fn update(
conn: &DatabaseConnection,
id: i32,
name: Option<String>,
command: Option<String>,
sort_order: Option<i32>,
) -> Result<FolderCommandInfo, DbError> {
let row = folder_command::Entity::find_by_id(id)
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("FolderCommand {} not found", id)))?;
let mut active = row.into_active_model();
if let Some(n) = name {
active.name = Set(n);
}
if let Some(c) = command {
active.command = Set(c);
}
if let Some(s) = sort_order {
active.sort_order = Set(s);
}
active.updated_at = Set(Utc::now());
let model = active.update(conn).await?;
Ok(to_info(model))
}
pub async fn delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> {
folder_command::Entity::delete_by_id(id).exec(conn).await?;
Ok(())
}
pub async fn reorder(
conn: &DatabaseConnection,
folder_id: i32,
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))
.collect::<Vec<_>>()
.join(" ");
let id_list = ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"UPDATE folder_command SET sort_order = CASE id {case_expr} END, updated_at = '{now_str}' WHERE folder_id = {folder_id} AND id IN ({id_list})"
);
conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
.await?;
Ok(())
}

View File

@@ -0,0 +1,246 @@
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, folder_opened_conversation};
use crate::db::error::DbError;
use crate::models::agent::AgentType;
use crate::models::{FolderDetail, FolderHistoryEntry, OpenedConversation};
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, opened: Vec<OpenedConversation>) -> 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,
parent_branch: m.parent_branch,
default_agent_type,
last_opened_at: m.last_opened_at,
opened_conversations: opened,
}
}
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?;
match row {
None => Ok(None),
Some(folder_model) => {
let opened = load_opened_conversations(conn, folder_model.id).await?;
Ok(Some(to_detail(folder_model, opened)))
}
}
}
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 active = folder::ActiveModel {
id: NotSet,
name: Set(name),
path: Set(path.to_string()),
git_branch: Set(None),
parent_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),
};
active.insert(conn).await?
};
Ok(to_entry(model))
}
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 save_opened_conversations(
conn: &DatabaseConnection,
folder_id: i32,
items: Vec<OpenedConversation>,
) -> Result<(), DbError> {
// Delete all existing opened conversations for this folder
folder_opened_conversation::Entity::delete_many()
.filter(folder_opened_conversation::Column::FolderId.eq(folder_id))
.exec(conn)
.await?;
if items.is_empty() {
return Ok(());
}
// Batch insert with raw SQL for efficiency
let now = Utc::now();
let now_str = now.format("%Y-%m-%d %H:%M:%S %:z").to_string();
let mut values = Vec::with_capacity(items.len());
for item in &items {
let agent_str = serde_json::to_value(item.agent_type)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default();
values.push(format!(
"({}, {}, {}, {}, {}, '{}', '{}', '{}')",
folder_id,
item.conversation_id,
item.position,
item.is_active as i32,
item.is_pinned as i32,
agent_str,
now_str,
now_str,
));
}
let sql = format!(
"INSERT INTO folder_opened_conversation (folder_id, conversation_id, position, is_active, is_pinned, agent_type, created_at, updated_at) VALUES {}",
values.join(", ")
);
conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
.await?;
Ok(())
}
async fn load_opened_conversations(
conn: &DatabaseConnection,
folder_id: i32,
) -> Result<Vec<OpenedConversation>, DbError> {
let rows = folder_opened_conversation::Entity::find()
.filter(folder_opened_conversation::Column::FolderId.eq(folder_id))
.order_by_asc(folder_opened_conversation::Column::Position)
.all(conn)
.await?;
Ok(rows
.into_iter()
.filter_map(|r| {
let agent_type = parse_agent_type(&Some(r.agent_type))?;
Some(OpenedConversation {
conversation_id: r.conversation_id,
agent_type,
position: r.position,
is_active: r.is_active,
is_pinned: r.is_pinned,
})
})
.collect())
}
pub async fn set_folder_parent_branch(
conn: &DatabaseConnection,
folder_id: i32,
parent_branch: Option<String>,
) -> 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.parent_branch = Set(parent_branch);
active.updated_at = Set(Utc::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())
}

View File

@@ -0,0 +1,99 @@
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
QueryFilter, Set,
};
use crate::db::entities::conversation;
use crate::db::error::DbError;
use crate::models::{AgentType, ImportResult};
use crate::parsers::claude::ClaudeParser;
use crate::parsers::codex::CodexParser;
use crate::parsers::gemini::GeminiParser;
use crate::parsers::opencode::OpenCodeParser;
use crate::parsers::{path_eq_for_matching, AgentParser};
pub async fn import_local_conversations(
conn: &DatabaseConnection,
folder_id: i32,
folder_path: &str,
) -> Result<ImportResult, DbError> {
let path = folder_path.to_string();
// Run parsers in blocking task since they do filesystem I/O
let summaries = tokio::task::spawn_blocking(move || {
let parsers: Vec<(AgentType, Box<dyn AgentParser>)> = vec![
(AgentType::ClaudeCode, Box::new(ClaudeParser::new())),
(AgentType::Codex, Box::new(CodexParser::new())),
(AgentType::OpenCode, Box::new(OpenCodeParser::new())),
(AgentType::Gemini, Box::new(GeminiParser::new())),
];
let mut matched = Vec::new();
for (at, parser) in &parsers {
match parser.list_conversations() {
Ok(convs) => {
for c in convs {
if c.folder_path
.as_deref()
.map(|p| path_eq_for_matching(p, path.as_str()))
.unwrap_or(false)
{
matched.push((*at, c));
}
}
}
Err(e) => {
eprintln!("Error listing {} conversations: {}", at, e);
}
}
}
matched
})
.await
.map_err(|e| DbError::Migration(e.to_string()))?;
let mut imported = 0u32;
let mut skipped = 0u32;
for (agent_type, summary) in &summaries {
let at_str = serde_json::to_value(agent_type)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
// Check if already imported
let exists = conversation::Entity::find()
.filter(conversation::Column::ExternalId.eq(&summary.id))
.filter(conversation::Column::AgentType.eq(&at_str))
.one(conn)
.await?;
if exists.is_some() {
skipped += 1;
continue;
}
let now = Utc::now();
let conv = conversation::ActiveModel {
id: NotSet,
folder_id: Set(folder_id),
title: Set(summary.title.clone()),
agent_type: Set(at_str.clone()),
status: Set(conversation::ConversationStatus::Completed),
model: Set(summary.model.clone()),
git_branch: Set(summary.git_branch.clone()),
external_id: Set(Some(summary.id.clone())),
parent_id: Set(None),
message_count: Set(summary.message_count as i32),
created_at: Set(summary.started_at),
updated_at: Set(now),
deleted_at: Set(None),
};
conv.insert(conn).await?;
imported += 1;
}
Ok(ImportResult { imported, skipped })
}

View File

@@ -0,0 +1,6 @@
pub mod agent_setting_service;
pub mod app_metadata_service;
pub mod conversation_service;
pub mod folder_command_service;
pub mod folder_service;
pub mod import_service;