refactor(workspace): migrate from per-folder windows to single-window workspace

Replace the legacy folder + welcome routes with a unified /workspace route
that hosts all folders, conversations, tabs, and terminals in one window.

- Persist opened tabs to the database (opened_tabs entity + migration)
  so tab layout survives restarts and deep-link bootstrap restores state
- Replace FolderContext shim with AppWorkspaceProvider, ActiveFolderProvider,
  and TabProvider; expose both opened (folders) and full DB (allFolders)
  listings via list_all_folder_details
- Return conversations across all non-deleted folders from list_all when
  no folder filter is given, so the sidebar can show every folder's history
- Add ConversationContextBar above the chat input with folder picker
  (auto-opens unopened folders on select), branch picker, and commit /
  push / merge / stash entries to restore BranchDropdown functionality
- Rework sidebar with stats header, search, flat / folder-grouped view
  modes (localStorage-persisted), reveal-in-sidebar event subscriber,
  and per-folder context menu (focus, close tabs, remove from workspace);
  indent conversations under folder headers in grouped mode
- Gate terminal creation on active folder and show folder context
- Remove deprecated BranchDropdown, FolderNameDropdown, welcome route,
  and per-folder window commands
- Localize all new strings across 10 locales
This commit is contained in:
xintaofei
2026-04-20 21:22:36 +08:00
parent 10801bf393
commit d9323d7399
89 changed files with 3701 additions and 2743 deletions

View File

@@ -1,14 +1,14 @@
use chrono::Utc;
use sea_orm::DatabaseConnection;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter,
QueryOrder, Set,
};
use crate::db::entities::{folder, folder_opened_conversation};
use crate::db::entities::folder;
use crate::db::error::DbError;
use crate::models::agent::AgentType;
use crate::models::{FolderDetail, FolderHistoryEntry, OpenedConversation};
use crate::models::{FolderDetail, FolderHistoryEntry};
fn to_entry(m: folder::Model) -> FolderHistoryEntry {
FolderHistoryEntry {
@@ -24,7 +24,7 @@ fn parse_agent_type(s: &Option<String>) -> Option<AgentType> {
.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 {
fn to_detail(m: folder::Model) -> FolderDetail {
let default_agent_type = parse_agent_type(&m.default_agent_type);
FolderDetail {
id: m.id,
@@ -34,7 +34,6 @@ fn to_detail(m: folder::Model, opened: Vec<OpenedConversation>) -> FolderDetail
parent_branch: m.parent_branch,
default_agent_type,
last_opened_at: m.last_opened_at,
opened_conversations: opened,
}
}
@@ -47,13 +46,7 @@ pub async fn get_folder_by_id(
.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)))
}
}
Ok(row.map(to_detail))
}
pub async fn add_folder(
@@ -126,80 +119,6 @@ pub async fn remove_folder(conn: &DatabaseConnection, path: &str) -> Result<(),
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,
@@ -244,3 +163,28 @@ pub async fn list_open_folders(
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_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_desc(folder::Column::LastOpenedAt)
.all(conn)
.await?;
Ok(rows.into_iter().map(to_detail).collect())
}