diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 1509152..bd3eb61 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["welcome", "folder-*", "commit-*", "merge-*", "stash-*", "push-*", "settings", "project-boot"], + "windows": ["main", "commit-*", "merge-*", "stash-*", "push-*", "settings", "project-boot"], "permissions": [ "core:default", "core:window:default", diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index 0abee9c..7281b60 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -6,8 +6,7 @@ "linux" ], "windows": [ - "welcome", - "folder-*", + "main", "commit-*", "merge-*", "stash-*", diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index b82ce93..94eae99 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -19,15 +19,38 @@ use crate::parsers::{path_eq_for_matching, AgentParser, ParseError}; #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn list_folder_conversations( +pub async fn list_all_conversations( db: tauri::State<'_, AppDatabase>, - folder_id: i32, + folder_ids: Option>, agent_type: Option, search: Option, sort_by: Option, status: Option, ) -> Result, AppCommandError> { - conversation_service::list_by_folder(&db.conn, folder_id, agent_type, search, sort_by, status) + conversation_service::list_all(&db.conn, folder_ids, agent_type, search, sort_by, status) + .await + .map_err(AppCommandError::from) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_opened_tabs( + db: tauri::State<'_, AppDatabase>, +) -> Result, AppCommandError> { + use crate::db::service::tab_service; + tab_service::list_all_tabs(&db.conn) + .await + .map_err(AppCommandError::from) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn save_opened_tabs( + db: tauri::State<'_, AppDatabase>, + items: Vec, +) -> Result<(), AppCommandError> { + use crate::db::service::tab_service; + tab_service::save_all_tabs(&db.conn, items) .await .map_err(AppCommandError::from) } diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 8fe51b1..9e644c3 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -23,7 +23,7 @@ use crate::db::service::folder_service; use crate::db::AppDatabase; use crate::models::GitCredentials; #[cfg(feature = "tauri-runtime")] -use crate::models::{FolderDetail, FolderHistoryEntry, OpenedConversation}; +use crate::models::{FolderDetail, FolderHistoryEntry}; use crate::web::event_bridge::EventEmitter; /// Configure a git command for remote operations: @@ -526,12 +526,52 @@ pub async fn remove_folder_from_history( #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn save_folder_opened_conversations( +pub async fn list_open_folder_details( + db: tauri::State<'_, AppDatabase>, +) -> Result, AppCommandError> { + folder_service::list_open_folder_details(&db.conn) + .await + .map_err(AppCommandError::from) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_all_folder_details( + db: tauri::State<'_, AppDatabase>, +) -> Result, AppCommandError> { + folder_service::list_all_folder_details(&db.conn) + .await + .map_err(AppCommandError::from) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn open_folder_by_id( db: tauri::State<'_, AppDatabase>, folder_id: i32, - items: Vec, -) -> Result<(), DbError> { - folder_service::save_opened_conversations(&db.conn, folder_id, items).await +) -> Result { + folder_service::set_folder_open(&db.conn, folder_id, true) + .await + .map_err(AppCommandError::from)?; + folder_service::get_folder_by_id(&db.conn, folder_id) + .await + .map_err(AppCommandError::from)? + .ok_or_else(|| AppCommandError::not_found(format!("Folder {folder_id} not found"))) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn remove_folder_from_workspace( + db: tauri::State<'_, AppDatabase>, + folder_id: i32, +) -> Result<(), AppCommandError> { + use crate::db::service::tab_service; + tab_service::delete_tabs_for_folder(&db.conn, folder_id) + .await + .map_err(AppCommandError::from)?; + folder_service::set_folder_open(&db.conn, folder_id, false) + .await + .map_err(AppCommandError::from) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 0850a97..065b57a 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -10,7 +10,7 @@ use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; use crate::app_error::AppCommandError; use crate::db::AppDatabase; use crate::db::service::app_metadata_service; -use crate::models::FolderHistoryEntry; +use crate::models::FolderDetail; /// Base traffic-light position (logical px) at 100 % zoom. #[cfg(target_os = "macos")] @@ -85,10 +85,6 @@ pub struct CommitWindowState { owner_by_commit_label: Mutex>, } -pub fn folder_window_label(folder_id: i32) -> String { - format!("folder-{folder_id}") -} - /// Detect macOS system dark mode via `defaults read`. /// Result is cached for the process lifetime via `OnceLock`. #[cfg(target_os = "macos")] @@ -288,13 +284,6 @@ impl CommitWindowState { } } -fn get_folder_id_from_window(window: &tauri::WebviewWindow) -> Option { - let url = window.url().ok()?; - url.query_pairs() - .find(|(key, _)| key == "id") - .and_then(|(_, value)| value.parse::().ok()) -} - fn resolve_settings_route(section: Option<&str>) -> &'static str { match section { Some("appearance") => "settings/appearance", @@ -331,103 +320,36 @@ fn resolve_settings_target(section: Option<&str>, agent_type: Option<&str>) -> S route.to_string() } -#[cfg(feature = "tauri-runtime")] -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn list_open_folders( - app: AppHandle, - db: tauri::State<'_, AppDatabase>, -) -> Result, AppCommandError> { - let windows = app.webview_windows(); - let mut folder_ids: Vec = Vec::new(); - - for (label, window) in &windows { - if label.starts_with("folder-") { - if let Some(id) = get_folder_id_from_window(window) { - folder_ids.push(id); - } - } - } - - let all_folders = crate::db::service::folder_service::list_folders(&db.conn) - .await - .map_err(AppCommandError::from)?; - - let open_folders: Vec = all_folders - .into_iter() - .filter(|f| folder_ids.contains(&f.id)) - .collect(); - - Ok(open_folders) -} - -#[cfg(feature = "tauri-runtime")] -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn focus_folder_window(app: AppHandle, folder_id: i32) -> Result<(), AppCommandError> { - let windows = app.webview_windows(); - for (label, window) in &windows { - if label.starts_with("folder-") { - if let Some(id) = get_folder_id_from_window(window) { - if id == folder_id { - window.set_focus().map_err(|e| { - AppCommandError::window("Failed to focus folder window", e.to_string()) - })?; - return Ok(()); - } - } - } - } - Err( - AppCommandError::not_found(format!("No open window for folder {folder_id}")) - .with_detail(format!("folder_id={folder_id}")), - ) -} - #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn open_folder_window( app: AppHandle, db: tauri::State<'_, AppDatabase>, path: String, -) -> Result<(), AppCommandError> { - // Add to history via DB +) -> Result { + // Single-window workspace: upsert the folder (is_open = true), close any + // legacy project-boot window, and return the full detail for the frontend + // to add to its workspace state. let entry = crate::db::service::folder_service::add_folder(&db.conn, &path) .await .map_err(AppCommandError::from)?; - let label = folder_window_label(entry.id); - if let Some(existing) = app.get_webview_window(&label) { - post_window_setup(&existing); - let _ = existing.unminimize(); - existing - .set_focus() - .map_err(|e| AppCommandError::window("Failed to focus folder window", e.to_string()))?; - if let Some(w) = app.get_webview_window("welcome") { - let _ = w.close(); - } - if let Some(w) = app.get_webview_window("project-boot") { - let _ = w.close(); - } - return Ok(()); - } - - let url = WebviewUrl::App(format!("folder?id={}", entry.id).into()); - let builder = WebviewWindowBuilder::new(&app, &label, url) - .title(&entry.name) - .inner_size(1260.0, 860.0) - .min_inner_size(900.0, 600.0); - let folder_window = apply_platform_window_style(builder) - .build() - .map_err(|e| AppCommandError::window("Failed to open folder window", e.to_string()))?; - post_window_setup(&folder_window); - - // Close welcome and project-boot windows - if let Some(w) = app.get_webview_window("welcome") { - let _ = w.close(); - } if let Some(w) = app.get_webview_window("project-boot") { let _ = w.close(); } - Ok(()) + + let folder = crate::db::service::folder_service::get_folder_by_id(&db.conn, entry.id) + .await + .map_err(AppCommandError::from)? + .ok_or_else(|| AppCommandError::not_found("Folder not found after add"))?; + + // Bring the main window to focus if it exists + if let Some(main) = app.get_webview_window("main") { + let _ = main.unminimize(); + let _ = main.set_focus(); + } + + Ok(folder) } #[cfg(feature = "tauri-runtime")] @@ -714,24 +636,6 @@ pub async fn cleanup_dangling_merge(app: &AppHandle, merge_window_label: &str) { } } -pub fn open_welcome_window(app: &AppHandle) -> Result<(), AppCommandError> { - if let Some(existing) = app.get_webview_window("welcome") { - post_window_setup(&existing); - return Ok(()); - } - let url = WebviewUrl::App("welcome".into()); - let builder = WebviewWindowBuilder::new(app, "welcome", url) - .title("Codeg") - .inner_size(800.0, 520.0) - .min_inner_size(600.0, 400.0) - .center(); - let welcome_window = apply_platform_window_style(builder) - .build() - .map_err(|e| AppCommandError::window("Failed to open welcome window", e.to_string()))?; - post_window_setup(&welcome_window); - Ok(()) -} - #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn open_stash_window( @@ -818,18 +722,13 @@ pub async fn open_project_boot_window( app: AppHandle, source: Option, ) -> Result<(), AppCommandError> { + let _ = source; if let Some(existing) = app.get_webview_window("project-boot") { post_window_setup(&existing); let _ = existing.unminimize(); existing.set_focus().map_err(|e| { AppCommandError::window("Failed to focus project boot window", e.to_string()) })?; - // Close welcome if opened from welcome - if source.as_deref() == Some("welcome") { - if let Some(w) = app.get_webview_window("welcome") { - let _ = w.close(); - } - } return Ok(()); } @@ -846,13 +745,6 @@ pub async fn open_project_boot_window( })?; post_window_setup(&window); - // Close welcome if opened from welcome - if source.as_deref() == Some("welcome") { - if let Some(w) = app.get_webview_window("welcome") { - let _ = w.close(); - } - } - Ok(()) } diff --git a/src-tauri/src/db/entities/folder.rs b/src-tauri/src/db/entities/folder.rs index 4ea6ec0..8eb3eba 100644 --- a/src-tauri/src/db/entities/folder.rs +++ b/src-tauri/src/db/entities/folder.rs @@ -23,8 +23,8 @@ 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::opened_tab::Entity")] + OpenedTabs, #[sea_orm(has_many = "super::folder_command::Entity")] FolderCommands, @@ -36,9 +36,9 @@ impl Related for Entity { } } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::OpenedConversations.def() + Relation::OpenedTabs.def() } } diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index 9ac4302..a19ec29 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -6,6 +6,6 @@ pub mod chat_channel_sender_context; pub mod conversation; pub mod folder; pub mod folder_command; -pub mod folder_opened_conversation; pub mod model_provider; +pub mod opened_tab; pub mod prelude; diff --git a/src-tauri/src/db/entities/folder_opened_conversation.rs b/src-tauri/src/db/entities/opened_tab.rs similarity index 92% rename from src-tauri/src/db/entities/folder_opened_conversation.rs rename to src-tauri/src/db/entities/opened_tab.rs index b505958..527bf0e 100644 --- a/src-tauri/src/db/entities/folder_opened_conversation.rs +++ b/src-tauri/src/db/entities/opened_tab.rs @@ -1,16 +1,16 @@ use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] -#[sea_orm(table_name = "folder_opened_conversation")] +#[sea_orm(table_name = "opened_tab")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub folder_id: i32, - pub conversation_id: i32, + pub conversation_id: Option, + pub agent_type: String, pub position: i32, pub is_active: bool, pub is_pinned: bool, - pub agent_type: String, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, } diff --git a/src-tauri/src/db/entities/prelude.rs b/src-tauri/src/db/entities/prelude.rs index e2cb48c..320b617 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -8,5 +8,5 @@ pub use super::chat_channel_sender_context::Entity as ChatChannelSenderContext; 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; pub use super::model_provider::Entity as ModelProvider; +pub use super::opened_tab::Entity as OpenedTab; diff --git a/src-tauri/src/db/migration/m20260420_000001_opened_tabs.rs b/src-tauri/src/db/migration/m20260420_000001_opened_tabs.rs new file mode 100644 index 0000000..984fe52 --- /dev/null +++ b/src-tauri/src/db/migration/m20260420_000001_opened_tabs.rs @@ -0,0 +1,132 @@ +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 + .drop_table( + Table::drop() + .table(FolderOpenedConversation::Table) + .if_exists() + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(OpenedTab::Table) + .if_not_exists() + .col( + ColumnDef::new(OpenedTab::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(OpenedTab::FolderId).integer().not_null()) + .col(ColumnDef::new(OpenedTab::ConversationId).integer().null()) + .col(ColumnDef::new(OpenedTab::AgentType).string().not_null()) + .col(ColumnDef::new(OpenedTab::Position).integer().not_null()) + .col( + ColumnDef::new(OpenedTab::IsActive) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(OpenedTab::IsPinned) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(OpenedTab::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(OpenedTab::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .from(OpenedTab::Table, OpenedTab::FolderId) + .to(Folder::Table, Folder::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from(OpenedTab::Table, OpenedTab::ConversationId) + .to(Conversation::Table, Conversation::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_opened_tabs_folder_id") + .table(OpenedTab::Table) + .col(OpenedTab::FolderId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_opened_tabs_position") + .table(OpenedTab::Table) + .col(OpenedTab::Position) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(OpenedTab::Table).if_exists().to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum OpenedTab { + Table, + Id, + FolderId, + ConversationId, + AgentType, + Position, + IsActive, + IsPinned, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum FolderOpenedConversation { + Table, +} + +#[derive(DeriveIden)] +enum Folder { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Conversation { + Table, + Id, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 8698cbe..95fbe8c 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -9,6 +9,7 @@ mod m20260330_000001_chat_channel; mod m20260401_000001_chat_channel_sender_context; mod m20260404_000001_model_provider; mod m20260406_000001_agent_setting_model_provider; +mod m20260420_000001_opened_tabs; pub struct Migrator; #[async_trait::async_trait] @@ -24,6 +25,7 @@ impl MigratorTrait for Migrator { Box::new(m20260401_000001_chat_channel_sender_context::Migration), Box::new(m20260404_000001_model_provider::Migration), Box::new(m20260406_000001_agent_setting_model_provider::Migration), + Box::new(m20260420_000001_opened_tabs::Migration), ] } } diff --git a/src-tauri/src/db/service/conversation_service.rs b/src-tauri/src/db/service/conversation_service.rs index fd298f2..e34d32e 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -4,7 +4,7 @@ use sea_orm::{ QueryFilter, QueryOrder, Set, }; -use crate::db::entities::conversation; +use crate::db::entities::{conversation, folder}; use crate::db::error::DbError; use crate::models::{AgentType, DbConversationSummary}; @@ -195,3 +195,68 @@ pub async fn list_by_folder( Ok(summaries) } + +/// List conversations across folders. When `folder_ids` is `None`, queries all +/// When `folder_ids` is provided, results are scoped to that set. Otherwise +/// returns conversations across every non-deleted folder (open or not). +pub async fn list_all( + conn: &DatabaseConnection, + folder_ids: Option>, + agent_type: Option, + search: Option, + sort_by: Option, + status: Option, +) -> Result, DbError> { + let mut query = conversation::Entity::find() + .filter(conversation::Column::DeletedAt.is_null()); + + match folder_ids { + Some(ids) if !ids.is_empty() => { + query = query.filter(conversation::Column::FolderId.is_in(ids)); + } + _ => { + // Exclude conversations whose folder was soft-deleted. + let active_folder_ids: Vec = folder::Entity::find() + .filter(folder::Column::DeletedAt.is_null()) + .all(conn) + .await? + .into_iter() + .map(|m| m.id) + .collect(); + if active_folder_ids.is_empty() { + return Ok(Vec::new()); + } + query = query.filter(conversation::Column::FolderId.is_in(active_folder_ids)); + } + } + + 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)); + } + + if let Some(ref s) = search { + if !s.is_empty() { + query = query.filter(conversation::Column::Title.contains(s)); + } + } + + if let Some(ref st) = status { + if let Ok(status_enum) = serde_json::from_value::( + serde_json::Value::String(st.clone()), + ) { + query = query.filter(conversation::Column::Status.eq(status_enum)); + } + } + + query = match sort_by.as_deref() { + Some("oldest") => query.order_by_asc(conversation::Column::UpdatedAt), + _ => query.order_by_desc(conversation::Column::UpdatedAt), + }; + + let rows = query.all(conn).await?; + Ok(rows.into_iter().map(conv_to_summary).collect()) +} diff --git a/src-tauri/src/db/service/folder_service.rs b/src-tauri/src/db/service/folder_service.rs index ff1144a..e94e2d8 100644 --- a/src-tauri/src/db/service/folder_service.rs +++ b/src-tauri/src/db/service/folder_service.rs @@ -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) -> Option { .and_then(|v| serde_json::from_value(serde_json::Value::String(v.to_string())).ok()) } -fn to_detail(m: folder::Model, opened: Vec) -> 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) -> 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, -) -> 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, 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, 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, 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()) +} diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index 6572022..49f7c08 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -8,3 +8,4 @@ pub mod folder_service; pub mod import_service; pub mod model_provider_service; pub mod sender_context_service; +pub mod tab_service; diff --git a/src-tauri/src/db/service/tab_service.rs b/src-tauri/src/db/service/tab_service.rs new file mode 100644 index 0000000..54e8ed4 --- /dev/null +++ b/src-tauri/src/db/service/tab_service.rs @@ -0,0 +1,97 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ActiveValue::NotSet, ConnectionTrait, DatabaseConnection, DbBackend, + EntityTrait, QueryOrder, Set, Statement, +}; + +use crate::db::entities::opened_tab; +use crate::db::error::DbError; +use crate::models::agent::AgentType; +use crate::models::OpenedTab; + +fn parse_agent_type(s: &str) -> Option { + serde_json::from_value(serde_json::Value::String(s.to_string())).ok() +} + +pub async fn list_all_tabs(conn: &DatabaseConnection) -> Result, DbError> { + let rows = opened_tab::Entity::find() + .order_by_asc(opened_tab::Column::Position) + .all(conn) + .await?; + + Ok(rows + .into_iter() + .filter_map(|r| { + let agent_type = parse_agent_type(&r.agent_type)?; + Some(OpenedTab { + id: r.id, + folder_id: r.folder_id, + conversation_id: r.conversation_id, + agent_type, + position: r.position, + is_active: r.is_active, + is_pinned: r.is_pinned, + }) + }) + .collect()) +} + +/// Replace all tabs with the given list (full replacement). +/// Ensures exactly one `is_active = true` (first active wins; others forced false). +pub async fn save_all_tabs( + conn: &DatabaseConnection, + items: Vec, +) -> Result<(), DbError> { + opened_tab::Entity::delete_many().exec(conn).await?; + + if items.is_empty() { + return Ok(()); + } + + let now = Utc::now(); + let mut active_seen = false; + + 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(); + + let is_active = if item.is_active && !active_seen { + active_seen = true; + true + } else { + false + }; + + let active = opened_tab::ActiveModel { + id: NotSet, + folder_id: Set(item.folder_id), + conversation_id: Set(item.conversation_id), + agent_type: Set(agent_str), + position: Set(item.position), + is_active: Set(is_active), + is_pinned: Set(item.is_pinned), + created_at: Set(now), + updated_at: Set(now), + }; + active.insert(conn).await?; + } + + Ok(()) +} + +/// Delete all tabs that belong to a given folder (used when removing a folder +/// from the workspace). +pub async fn delete_tabs_for_folder( + conn: &DatabaseConnection, + folder_id: i32, +) -> Result<(), DbError> { + let sql = format!( + "DELETE FROM opened_tab WHERE folder_id = {}", + folder_id + ); + 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 ba69a09..45117f7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,14 +34,6 @@ mod tauri_app { static APP_QUITTING: AtomicBool = AtomicBool::new(false); - fn get_folder_id_from_url(window: &tauri::Window) -> Option { - let webview = window.get_webview_window(window.label())?; - let url = webview.url().ok()?; - url.query_pairs() - .find(|(key, _)| key == "id") - .and_then(|(_, value)| value.parse::().ok()) - } - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { if let Err(err) = fix_path_env::fix() { @@ -128,26 +120,18 @@ mod tauri_app { }); } - // Restore previously open folders or show welcome - let db = app.state::(); - let open_folders = tauri::async_runtime::block_on( - db::service::folder_service::list_open_folders(&db.conn), - ) - .unwrap_or_default(); - - if open_folders.is_empty() { - let _ = windows::open_welcome_window(app.handle()); - } else { - for entry in &open_folders { - let label = windows::folder_window_label(entry.id); - let url = tauri::WebviewUrl::App(format!("folder?id={}", entry.id).into()); - let builder = tauri::WebviewWindowBuilder::new(app, &label, url) - .title(&entry.name) - .inner_size(1260.0, 860.0) - .min_inner_size(900.0, 600.0); - if let Ok(w) = windows::apply_platform_window_style(builder).build() { - windows::post_window_setup(&w); - } + // Single-window workspace: ensure the main window exists. + // Workspace state (open folders, opened tabs, active tab) is + // restored by the frontend via `list_open_folder_details` / + // `list_opened_tabs` inside the main window. + if app.get_webview_window("main").is_none() { + let url = tauri::WebviewUrl::App("workspace".into()); + let builder = tauri::WebviewWindowBuilder::new(app, "main", url) + .title("Codeg") + .inner_size(1260.0, 860.0) + .min_inner_size(900.0, 600.0); + if let Ok(w) = windows::apply_platform_window_style(builder).build() { + windows::post_window_setup(&w); } } @@ -197,70 +181,31 @@ mod tauri_app { }); } - if label == "project-boot" - && matches!( - event, - tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed - ) + if label == "main" + && matches!(event, tauri::WindowEvent::CloseRequested { .. }) { let app = window.app_handle(); - if !APP_QUITTING.load(Ordering::Relaxed) { - let has_other = app - .webview_windows() - .keys() - .any(|l| *l != label && *l != "settings"); - if !has_other { - let _ = windows::open_welcome_window(app); - } + if let Some(cm) = app.try_state::() { + let disconnected = tauri::async_runtime::block_on( + cm.disconnect_by_owner_window(&label), + ); + eprintln!( + "[ACP] main window closing disconnected_connections={}", + disconnected + ); } - } - - if let tauri::WindowEvent::CloseRequested { .. } = event { - if label.starts_with("folder-") { - let app = window.app_handle(); - if let Some(cm) = app.try_state::() { - let disconnected = tauri::async_runtime::block_on( - cm.disconnect_by_owner_window(&label), - ); - eprintln!( - "[ACP] folder window closing label={} disconnected_connections={}", - label, disconnected - ); - } - - if !APP_QUITTING.load(Ordering::Relaxed) { - if let Some(folder_id) = get_folder_id_from_url(window) { - if let Some(db) = app.try_state::() { - let _ = tauri::async_runtime::block_on( - db::service::folder_service::set_folder_open( - &db.conn, folder_id, false, - ), - ); - } - } - } - - if let Some(tm) = app.try_state::() { - let killed = tm.kill_by_owner_window(&label); - eprintln!( - "[TERM] folder window closing label={} killed_terminals={}", - label, killed - ); - } - let has_other_folder = app - .webview_windows() - .keys() - .any(|l| l.starts_with("folder-") && *l != label); - if !has_other_folder && !APP_QUITTING.load(Ordering::Relaxed) { - let _ = windows::open_welcome_window(app); - } + if let Some(tm) = app.try_state::() { + let killed = tm.kill_by_owner_window(&label); + eprintln!("[TERM] main window closing killed_terminals={}", killed); } } }) .invoke_handler(tauri::generate_handler![ conversations::list_conversations, conversations::get_conversation, - conversations::list_folder_conversations, + conversations::list_all_conversations, + conversations::list_opened_tabs, + conversations::save_opened_tabs, conversations::import_local_conversations, conversations::get_folder_conversation, conversations::list_folders, @@ -273,6 +218,10 @@ mod tauri_app { conversations::delete_conversation, folders::load_folder_history, folders::get_folder, + folders::list_open_folder_details, + folders::list_all_folder_details, + folders::open_folder_by_id, + folders::remove_folder_from_workspace, folders::add_folder_to_history, folders::set_folder_parent_branch, folders::remove_folder_from_history, @@ -322,7 +271,6 @@ mod tauri_app { folders::git_resolve_conflict, folders::git_abort_operation, folders::git_continue_operation, - folders::save_folder_opened_conversations, workspace_state_commands::start_workspace_state_stream, workspace_state_commands::stop_workspace_state_stream, workspace_state_commands::get_workspace_snapshot, @@ -342,8 +290,6 @@ mod tauri_app { windows::open_folder_window, windows::open_commit_window, windows::open_settings_window, - windows::list_open_folders, - windows::focus_folder_window, windows::open_merge_window, windows::open_stash_window, windows::open_push_window, diff --git a/src-tauri/src/models/folder.rs b/src-tauri/src/models/folder.rs index 7288060..920b7c6 100644 --- a/src-tauri/src/models/folder.rs +++ b/src-tauri/src/models/folder.rs @@ -20,12 +20,13 @@ pub struct FolderDetail { pub parent_branch: Option, pub default_agent_type: Option, pub last_opened_at: DateTime, - pub opened_conversations: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenedConversation { - pub conversation_id: i32, +pub struct OpenedTab { + pub id: i32, + pub folder_id: i32, + pub conversation_id: Option, pub agent_type: AgentType, pub position: i32, pub is_active: bool, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 47386b6..0a89857 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -14,7 +14,7 @@ pub use conversation::{ DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats, SidebarData, }; -pub use folder::{FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedConversation}; +pub use folder::{FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedTab}; pub use message::{ AgentExecutionStats, AgentToolCall, ContentBlock, MessageRole, MessageTurn, TurnRole, TurnUsage, UnifiedMessage, diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index 0e72ed0..1000045 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -9,24 +9,24 @@ use crate::commands::conversations as conv_commands; use crate::db::service::{conversation_service, folder_service, import_service}; use crate::models::*; -#[derive(Deserialize)] +#[derive(Deserialize, Default)] #[serde(rename_all = "camelCase")] -pub struct ListFolderConversationsParams { - pub folder_id: i32, +pub struct ListAllConversationsParams { + pub folder_ids: Option>, pub agent_type: Option, pub search: Option, pub sort_by: Option, pub status: Option, } -pub async fn list_folder_conversations( +pub async fn list_all_conversations( Extension(state): Extension>, - Json(params): Json, + Json(params): Json, ) -> Result>, AppCommandError> { let db = &state.db; - let result = conversation_service::list_by_folder( + let result = conversation_service::list_all( &db.conn, - params.folder_id, + params.folder_ids, params.agent_type, params.search, params.sort_by, @@ -37,6 +37,35 @@ pub async fn list_folder_conversations( Ok(Json(result)) } +pub async fn list_opened_tabs( + Extension(state): Extension>, +) -> Result>, AppCommandError> { + use crate::db::service::tab_service; + let db = &state.db; + let result = tab_service::list_all_tabs(&db.conn) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveOpenedTabsParams { + pub items: Vec, +} + +pub async fn save_opened_tabs( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + use crate::db::service::tab_service; + let db = &state.db; + tab_service::save_all_tabs(&db.conn, params.items) + .await + .map_err(AppCommandError::from)?; + Ok(Json(())) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListConversationsParams { diff --git a/src-tauri/src/web/handlers/folders.rs b/src-tauri/src/web/handlers/folders.rs index 36613f9..834e15e 100644 --- a/src-tauri/src/web/handlers/folders.rs +++ b/src-tauri/src/web/handlers/folders.rs @@ -53,45 +53,71 @@ pub struct AddFolderParams { pub path: String, } -/// Web equivalent of `open_folder_window`: adds the folder to DB and returns its ID. -/// The web client then navigates to `/folder?id=N` itself. -pub async fn open_folder_window( +/// Add the folder to the workspace (upsert + set is_open=true) and return its full detail. +/// Previously this spawned a new window; the new single-window workspace model +/// simply returns the folder info so the client can update its local state. +pub async fn open_folder( Extension(state): Extension>, Json(params): Json, -) -> Result, AppCommandError> { +) -> Result, AppCommandError> { let db = &state.db; let entry = folder_service::add_folder(&db.conn, ¶ms.path) .await .map_err(AppCommandError::from)?; - Ok(Json(entry)) + let folder = folder_service::get_folder_by_id(&db.conn, entry.id) + .await + .map_err(AppCommandError::from)? + .ok_or_else(|| AppCommandError::not_found("Folder not found after add"))?; + Ok(Json(folder)) } -pub async fn close_folder_window( +// --- New workspace handlers --- + +pub async fn list_open_folder_details( + Extension(state): Extension>, +) -> Result>, AppCommandError> { + let db = &state.db; + let result = folder_service::list_open_folder_details(&db.conn) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + +pub async fn list_all_folder_details( + Extension(state): Extension>, +) -> Result>, AppCommandError> { + let db = &state.db; + let result = folder_service::list_all_folder_details(&db.conn) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + +pub async fn open_folder_by_id( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let db = &state.db; + folder_service::set_folder_open(&db.conn, params.folder_id, true) + .await + .map_err(AppCommandError::from)?; + let folder = folder_service::get_folder_by_id(&db.conn, params.folder_id) + .await + .map_err(AppCommandError::from)? + .ok_or_else(|| AppCommandError::not_found("Folder not found"))?; + Ok(Json(folder)) +} + +pub async fn remove_folder_from_workspace( Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { + use crate::db::service::tab_service; let db = &state.db; - folder_service::set_folder_open(&db.conn, params.folder_id, false) + tab_service::delete_tabs_for_folder(&db.conn, params.folder_id) .await .map_err(AppCommandError::from)?; - Ok(Json(())) -} - -// --- New handlers below --- - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SaveFolderOpenedConversationsParams { - pub folder_id: i32, - pub items: Vec, -} - -pub async fn save_folder_opened_conversations( - Extension(state): Extension>, - Json(params): Json, -) -> Result, AppCommandError> { - let db = &state.db; - folder_service::save_opened_conversations(&db.conn, params.folder_id, params.items) + folder_service::set_folder_open(&db.conn, params.folder_id, false) .await .map_err(AppCommandError::from)?; Ok(Json(())) diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index fe47666..3de92aa 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -34,13 +34,21 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: post(handlers::conversations::get_conversation), ) .route( - "/list_folder_conversations", - post(handlers::conversations::list_folder_conversations), + "/list_all_conversations", + post(handlers::conversations::list_all_conversations), ) .route( "/get_folder_conversation", post(handlers::conversations::get_folder_conversation), ) + .route( + "/list_opened_tabs", + post(handlers::conversations::list_opened_tabs), + ) + .route( + "/save_opened_tabs", + post(handlers::conversations::save_opened_tabs), + ) .route( "/import_local_conversations", post(handlers::conversations::import_local_conversations), @@ -81,13 +89,22 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: post(handlers::folders::list_open_folders), ) .route( - "/close_folder_window", - post(handlers::folders::close_folder_window), + "/list_open_folder_details", + post(handlers::folders::list_open_folder_details), + ) + .route( + "/list_all_folder_details", + post(handlers::folders::list_all_folder_details), ) .route("/get_folder", post(handlers::folders::get_folder)) + .route("/open_folder", post(handlers::folders::open_folder)) .route( - "/open_folder_window", - post(handlers::folders::open_folder_window), + "/open_folder_by_id", + post(handlers::folders::open_folder_by_id), + ) + .route( + "/remove_folder_from_workspace", + post(handlers::folders::remove_folder_from_workspace), ) .route( "/add_folder_to_history", @@ -105,10 +122,6 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: "/create_folder_directory", post(handlers::folders::create_folder_directory), ) - .route( - "/save_folder_opened_conversations", - post(handlers::folders::save_folder_opened_conversations), - ) .route("/get_git_branch", post(handlers::folders::get_git_branch)) .route( "/get_home_directory", diff --git a/src/app/folder/page.tsx b/src/app/folder/page.tsx deleted file mode 100644 index 8647af6..0000000 --- a/src/app/folder/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client" - -import { ConversationDetailPanel } from "@/components/conversations/conversation-detail-panel" - -export default function FolderPage() { - return -} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 2ca9423..4d7d913 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -16,7 +16,7 @@ export default function LoginPage() { // Desktop users skip login entirely if (isDesktop()) { - router.replace("/welcome") + router.replace("/workspace") return null } @@ -38,7 +38,7 @@ export default function LoginPage() { if (res.ok) { localStorage.setItem("codeg_token", token) - router.replace("/welcome") + router.replace("/workspace") } else if (res.status === 401) { setError("Token 无效,请检查后重试") } else { diff --git a/src/app/page.tsx b/src/app/page.tsx index 95e775e..e0a5bd1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -8,7 +8,7 @@ export default function Page() { const router = useRouter() useEffect(() => { if (isDesktop()) { - router.replace("/welcome") + router.replace("/workspace") return } // Web mode: validate token before entering app @@ -28,7 +28,7 @@ export default function Page() { }) .then((res) => { if (res.ok) { - router.replace("/welcome") + router.replace("/workspace") } else { localStorage.removeItem("codeg_token") router.replace("/login") diff --git a/src/app/welcome/layout.tsx b/src/app/welcome/layout.tsx deleted file mode 100644 index 078dd03..0000000 --- a/src/app/welcome/layout.tsx +++ /dev/null @@ -1,8 +0,0 @@ -"use client" - -import type { ReactNode } from "react" -import { GitCredentialProvider } from "@/contexts/git-credential-context" - -export default function WelcomeLayout({ children }: { children: ReactNode }) { - return {children} -} diff --git a/src/app/welcome/page.tsx b/src/app/welcome/page.tsx deleted file mode 100644 index f7cc45d..0000000 --- a/src/app/welcome/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client" - -import { useEffect } from "react" -import { useTranslations } from "next-intl" -import { WelcomeScreen } from "@/components/welcome/welcome-screen" - -export default function WelcomePage() { - const t = useTranslations("WelcomePage") - - useEffect(() => { - document.title = `${t("title")} - codeg` - }, [t]) - - return -} diff --git a/src/app/folder/layout.tsx b/src/app/workspace/layout.tsx similarity index 87% rename from src/app/folder/layout.tsx rename to src/app/workspace/layout.tsx index 96cc0b8..54663f0 100644 --- a/src/app/folder/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -8,12 +8,15 @@ import { useRef, useState, } from "react" -import { useSearchParams } from "next/navigation" import type { ImperativePanelGroupHandle } from "react-resizable-panels" import { FolderTitleBar } from "@/components/layout/folder-title-bar" import { Sidebar } from "@/components/layout/sidebar" import { StatusBar } from "@/components/layout/status-bar" -import { FolderProvider } from "@/contexts/folder-context" +import { AppWorkspaceProvider } from "@/contexts/app-workspace-context" +import { + ActiveFolderProvider, + useActiveFolder, +} from "@/contexts/active-folder-context" import { TaskProvider } from "@/contexts/task-context" import { AlertProvider } from "@/contexts/alert-context" import { @@ -43,23 +46,22 @@ import { AuxPanel } from "@/components/layout/aux-panel" import { FileWorkspaceTabBar } from "@/components/files/file-workspace-tab-bar" import { FileWorkspacePanel } from "@/components/files/file-workspace-panel" import { AppToaster } from "@/components/ui/app-toaster" +import { DeepLinkBootstrap } from "@/components/workspace/deep-link-bootstrap" import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable" -import type { AgentType } from "@/lib/types" import { cn } from "@/lib/utils" -import { useFolderContext } from "@/contexts/folder-context" import { useIsMobile } from "@/hooks/use-mobile" import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" -function FolderDocumentTitle() { - const { folder } = useFolderContext() +function WorkspaceDocumentTitle() { + const { activeFolder } = useActiveFolder() useEffect(() => { - document.title = folder ? `${folder.name} - codeg` : "codeg" - }, [folder]) + document.title = activeFolder ? `${activeFolder.name} - codeg` : "codeg" + }, [activeFolder]) return null } @@ -80,7 +82,6 @@ const MIN_CENTER_WIDTH_PX = 420 const MIN_WORKSPACE_HEIGHT_PX = 220 const LAYOUT_EPSILON = 0.25 -/** Syncs open tab keys from TabProvider to AcpConnectionsProvider */ function TabKeysSync() { const { tabs } = useTabContext() const { registerOpenTabKeys } = useAcpActions() @@ -149,8 +150,7 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) { panelGroup.setLayout(layout) appliedLayoutRef.current = layout } catch { - // The group can be transiently unavailable while registering panels. - // onLayout will retry once registration completes. + /* retry via onLayout */ } }, []) @@ -158,9 +158,6 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) { if (mode === "fusion") { applyLayout(fusionLayoutRef.current) } - // Non-fusion modes keep panels at their current sizes to preserve - // scroll positions. CSS overlay on the active section provides - // full-width display (see absolute inset-0 below). }, [applyLayout, mode]) const handleLayout = useCallback( @@ -251,7 +248,6 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) { function MobileWorkspaceContent({ children }: { children: React.ReactNode }) { const { mode } = useWorkspaceContext() - // On mobile, fusion mode falls back to conversation view const showConversation = mode === "conversation" || mode === "fusion" return ( @@ -294,7 +290,6 @@ function MobileFolderWorkspaceShell({ return (
- {/* Sidebar Sheet (left) */} - {/* Main workspace */}
{children}
- {/* Aux panel Sheet (right) */} - {/* Terminal Sheet (bottom) */} - - - - - - - - - - - - - - {children} - - - - - - - - - - - - + + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + ) } -export default function FolderLayout({ +export default function WorkspaceLayout({ children, }: { children: React.ReactNode }) { return ( - {children} + {children} ) } diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx new file mode 100644 index 0000000..26f6f6c --- /dev/null +++ b/src/app/workspace/page.tsx @@ -0,0 +1,15 @@ +"use client" + +import { ConversationDetailPanel } from "@/components/conversations/conversation-detail-panel" +import { useAppWorkspace } from "@/contexts/app-workspace-context" +import { WorkspaceEmpty } from "@/components/workspace-empty/workspace-empty" + +export default function WorkspacePage() { + const { folders, foldersHydrated } = useAppWorkspace() + + if (foldersHydrated && folders.length === 0) { + return + } + + return +} diff --git a/src/components/ai-elements/link-safety.tsx b/src/components/ai-elements/link-safety.tsx index c95a738..6c21085 100644 --- a/src/components/ai-elements/link-safety.tsx +++ b/src/components/ai-elements/link-safety.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl" import { openUrl } from "@/lib/platform" import type { LinkSafetyConfig, LinkSafetyModalProps } from "streamdown" import { toast } from "sonner" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { AlertDialog, @@ -223,7 +223,7 @@ function LinkSafetyModal({ export function useStreamdownLinkSafety(): LinkSafetyConfig { const t = useTranslations("Folder.chat.linkSafety") - const { folder } = useFolderContext() + const { activeFolder: folder } = useActiveFolder() const folderPath = folder?.path const { openFilePreview } = useWorkspaceContext() diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index 4b71409..49a46f4 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -12,6 +12,7 @@ import type { AvailableCommandInfo, } from "@/lib/types" import type { QueuedMessage } from "@/hooks/use-message-queue" +import { ConversationContextBar } from "@/components/chat/conversation-context-bar" import { MessageInput } from "@/components/chat/message-input" import { MessageQueueDisplay } from "@/components/chat/message-queue-display" @@ -92,6 +93,7 @@ export const ChatInput = memo(function ChatInput({ className="p-4 pt-0" onContextMenu={(event) => event.stopPropagation()} > + {queue && queue.length > 0 && onQueueReorder && diff --git a/src/components/chat/conversation-context-bar.tsx b/src/components/chat/conversation-context-bar.tsx new file mode 100644 index 0000000..0963ab8 --- /dev/null +++ b/src/components/chat/conversation-context-bar.tsx @@ -0,0 +1,640 @@ +"use client" + +import { memo, useCallback, useEffect, useMemo, useState } from "react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { + Check, + ChevronsUpDown, + Folder, + FolderOpen, + GitBranch, + GitCommit, + GitMerge, + Loader2, + MoreHorizontal, + Upload, + Plus, + Archive, + Trash2, +} from "lucide-react" +import { useAppWorkspace } from "@/contexts/app-workspace-context" +import { useTabContext } from "@/contexts/tab-context" +import { useTaskContext } from "@/contexts/task-context" +import { + gitListAllBranches, + gitCheckout, + gitNewBranch, + gitDeleteBranch, + openCommitWindow, + openPushWindow, + openStashWindow, + openMergeWindow, +} from "@/lib/api" +import { isDesktop, openFileDialog } from "@/lib/platform" +import type { GitBranchList } from "@/lib/types" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Separator } from "@/components/ui/separator" +import { cn } from "@/lib/utils" + +export const ConversationContextBar = memo(function ConversationContextBar() { + const t = useTranslations("Folder.conversationContextBar") + const tBd = useTranslations("Folder.branchDropdown") + const { tabs, activeTabId, setTabFolder } = useTabContext() + const { + folders, + allFolders, + branches, + setBranch, + openFolder, + addFolderToWorkspaceById, + refreshFolder, + } = useAppWorkspace() + const { addTask, updateTask } = useTaskContext() + + const activeTab = useMemo( + () => tabs.find((x) => x.id === activeTabId) ?? null, + [tabs, activeTabId] + ) + + const activeFolder = useMemo( + () => + activeTab + ? (allFolders.find((f) => f.id === activeTab.folderId) ?? null) + : null, + [activeTab, allFolders] + ) + + if (!activeTab || !activeFolder) return null + + const isNewConversation = activeTab.conversationId == null + const currentBranch = + branches.get(activeFolder.id) ?? activeFolder.git_branch ?? null + + return ( + +
+ { + const target = allFolders.find((f) => f.id === folderId) + if (!target) return + const isOpen = folders.some((f) => f.id === folderId) + try { + const detail = isOpen + ? target + : await addFolderToWorkspaceById(folderId) + setTabFolder(activeTab.id, detail.id, detail.path) + toast.success(t("toasts.folderChanged", { name: detail.name })) + } catch (err) { + console.error( + "[ConversationContextBar] switch folder failed:", + err + ) + toast.error(t("toasts.openFolderFailed")) + } + }} + onOpenNewFolder={async () => { + try { + if (isDesktop()) { + const result = await openFileDialog({ + directory: true, + multiple: false, + }) + if (!result) return + const selected = Array.isArray(result) ? result[0] : result + const detail = await openFolder(selected) + setTabFolder(activeTab.id, detail.id, detail.path) + toast.success(t("toasts.folderChanged", { name: detail.name })) + } + } catch (err) { + console.error("[ConversationContextBar] open folder failed:", err) + toast.error(t("toasts.openFolderFailed")) + } + }} + labelOpenNew={t("openNewFolder")} + labelEmpty={t("noFolders")} + labelSearch={t("searchFolder")} + /> + + + + { + const taskId = `checkout-${activeFolder.id}-${Date.now()}` + addTask(taskId, tBd("tasks.checkoutTo", { branchName })) + updateTask(taskId, { status: "running" }) + try { + await gitCheckout(activeFolder.path, branchName) + setBranch(activeFolder.id, branchName) + await refreshFolder(activeFolder.id) + updateTask(taskId, { status: "completed" }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + updateTask(taskId, { status: "failed", error: msg }) + toast.error(msg) + } + }} + onNewBranch={async (branchName, startPoint) => { + const taskId = `new-branch-${activeFolder.id}-${Date.now()}` + addTask(taskId, tBd("tasks.newBranch", { name: branchName })) + updateTask(taskId, { status: "running" }) + try { + await gitNewBranch(activeFolder.path, branchName, startPoint) + setBranch(activeFolder.id, branchName) + await refreshFolder(activeFolder.id) + updateTask(taskId, { status: "completed" }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + updateTask(taskId, { status: "failed", error: msg }) + toast.error(msg) + } + }} + onDeleteBranch={async (branchName) => { + const taskId = `delete-branch-${activeFolder.id}-${Date.now()}` + addTask(taskId, tBd("tasks.deleteBranch", { branchName })) + updateTask(taskId, { status: "running" }) + try { + await gitDeleteBranch(activeFolder.path, branchName, false) + updateTask(taskId, { status: "completed" }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + updateTask(taskId, { status: "failed", error: msg }) + toast.error(msg) + } + }} + /> + +
+ + +
+ + ) +}) + +ConversationContextBar.displayName = "ConversationContextBar" + +// ============================================================================ +// FolderPicker +// ============================================================================ + +interface FolderPickerProps { + folders: { id: number; name: string; path: string }[] + currentFolderId: number + currentFolderName: string + editable: boolean + onSelect: (folderId: number) => void | Promise + onOpenNewFolder: () => void | Promise + labelOpenNew: string + labelEmpty: string + labelSearch: string +} + +const FolderPicker = memo(function FolderPicker({ + folders, + currentFolderId, + currentFolderName, + editable, + onSelect, + onOpenNewFolder, + labelOpenNew, + labelEmpty, + labelSearch, +}: FolderPickerProps) { + const [open, setOpen] = useState(false) + + const trigger = ( + + ) + + if (!editable) { + return ( + + {trigger} + {currentFolderName} + + ) + } + + return ( + + {trigger} + + + + + {labelEmpty} + + {folders.map((f) => ( + { + setOpen(false) + void onSelect(f.id) + }} + > + +
+ {f.name} + + {f.path} + +
+ {f.id === currentFolderId && ( + + )} +
+ ))} +
+ + + { + setOpen(false) + void onOpenNewFolder() + }} + > + + {labelOpenNew} + + +
+
+
+
+ ) +}) + +// ============================================================================ +// BranchPicker +// ============================================================================ + +interface BranchPickerProps { + folderId: number + folderPath: string + currentBranch: string | null + onCheckout: (branchName: string) => Promise + onNewBranch: (branchName: string, startPoint?: string) => Promise + onDeleteBranch: (branchName: string) => Promise +} + +const BranchPicker = memo(function BranchPicker({ + folderId, + folderPath, + currentBranch, + onCheckout, + onNewBranch, + onDeleteBranch, +}: BranchPickerProps) { + const t = useTranslations("Folder.conversationContextBar") + const tBd = useTranslations("Folder.branchDropdown") + const [open, setOpen] = useState(false) + const [branchList, setBranchList] = useState(null) + const [loading, setLoading] = useState(false) + const [newBranchOpen, setNewBranchOpen] = useState(false) + const [newBranchName, setNewBranchName] = useState("") + + const loadBranches = useCallback(async () => { + setLoading(true) + try { + const list = await gitListAllBranches(folderPath) + setBranchList(list) + } catch (err) { + console.error("[BranchPicker] list failed:", err) + setBranchList({ local: [], remote: [], worktree_branches: [] }) + } finally { + setLoading(false) + } + }, [folderPath]) + + useEffect(() => { + if (open) void loadBranches() + }, [open, loadBranches]) + + // Reset branches cache when folder changes + useEffect(() => { + setBranchList(null) + }, [folderId]) + + return ( + <> + + + + + + + + + {loading ? ( +
+ +
+ ) : ( + <> + {t("noBranches")} + {branchList && branchList.local.length > 0 && ( + + {branchList.local.map((b) => ( + { + setOpen(false) + if (b !== currentBranch) void onCheckout(b) + }} + > + + {b} + {b === currentBranch && ( + + )} + {b !== currentBranch && ( + { + e.stopPropagation() + setOpen(false) + void onDeleteBranch(b) + }} + /> + )} + + ))} + + )} + {branchList && branchList.remote.length > 0 && ( + + {branchList.remote.map((b) => ( + { + setOpen(false) + void onCheckout(b) + }} + > + + + {b} + + + ))} + + )} + + + { + setOpen(false) + setNewBranchName("") + setNewBranchOpen(true) + }} + > + + {tBd("newBranch")} + + + + )} +
+
+
+
+ + + + + {tBd("dialogs.newBranchTitle")} + +
+ {tBd("dialogs.newBranchDescription", { + branch: currentBranch ?? "-", + })} +
+ setNewBranchName(e.target.value)} + autoFocus + /> + + + + +
+
+ + ) +}) + +// ============================================================================ +// GitActionButtons +// ============================================================================ + +interface GitActionButtonsProps { + folderId: number + currentBranch: string | null +} + +const GitActionButtons = memo(function GitActionButtons({ + folderId, + currentBranch, +}: GitActionButtonsProps) { + const t = useTranslations("Folder.conversationContextBar") + const tBd = useTranslations("Folder.branchDropdown") + + const handleCommit = useCallback(async () => { + try { + await openCommitWindow(folderId) + } catch (err) { + console.error("[GitActions] commit failed:", err) + toast.error(tBd("toasts.openCommitWindowFailed")) + } + }, [folderId, tBd]) + + const handlePush = useCallback(async () => { + try { + await openPushWindow(folderId) + } catch (err) { + console.error("[GitActions] push failed:", err) + toast.error(tBd("toasts.openPushWindowFailed")) + } + }, [folderId, tBd]) + + const handleStash = useCallback(async () => { + try { + await openStashWindow(folderId) + } catch (err) { + console.error("[GitActions] stash failed:", err) + toast.error(t("toasts.openStashFailed")) + } + }, [folderId, t]) + + const handleMerge = useCallback(async () => { + try { + await openMergeWindow(folderId, "merge") + } catch (err) { + console.error("[GitActions] merge failed:", err) + toast.error(t("toasts.openMergeFailed")) + } + }, [folderId, t]) + + return ( +
+ + + + + {tBd("openCommitWindow")} + + + + + + + {tBd("pushCode")} + + + + + + + + + + {t("merge")} + + + + {tBd("stashChanges")} + + + + + {tBd("openCommitWindow")} + + + + {tBd("pushCode")} + + + +
+ ) +}) diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 87e4af2..61f0784 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -23,7 +23,8 @@ import { useTranslations } from "next-intl" import { toast } from "sonner" import { disposeTauriListener } from "@/lib/tauri-listener" import { useAcpActions } from "@/contexts/acp-connections-context" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" +import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useTabContext } from "@/contexts/tab-context" import { useSessionStats } from "@/contexts/session-stats-context" import { useTaskContext } from "@/contexts/task-context" @@ -159,8 +160,9 @@ const ConversationTabView = memo(function ConversationTabView({ const t = useTranslations("Folder.conversation") const tWelcome = useTranslations("Folder.chat.welcomeInputPanel") const sharedT = useTranslations("Folder.chat.shared") - const { folder, folderId, refreshConversations, updateConversationLocal } = - useFolderContext() + const { activeFolder: folder, activeFolderId } = useActiveFolder() + const { refreshConversations, updateConversationLocal } = useAppWorkspace() + const folderId = activeFolderId ?? 0 const { tabs, bindConversationTab, setTabRuntimeConversationId, pinTab } = useTabContext() const { setSessionStats } = useSessionStats() @@ -267,8 +269,8 @@ const ConversationTabView = memo(function ConversationTabView({ if (dbConversationId != null) { return buildConversationDraftStorageKey(selectedAgent, dbConversationId) } - return buildNewConversationDraftStorageKey({ folderId }) - }, [dbConversationId, folderId, selectedAgent]) + return buildNewConversationDraftStorageKey({ tabId }) + }, [dbConversationId, tabId, selectedAgent]) const workingDirForConnection = useMemo(() => { if (dbConversationId != null) { return detailLoading ? undefined : folder?.path @@ -624,7 +626,7 @@ const ConversationTabView = memo(function ConversationTabView({ effectiveConversationId ) moveMessageInputDraft( - buildNewConversationDraftStorageKey({ folderId }), + buildNewConversationDraftStorageKey({ tabId }), buildConversationDraftStorageKey(selectedAgent, newConversationId) ) statusUpdatedRef.current = false @@ -1034,13 +1036,9 @@ export function ConversationDetailPanel() { getSession, removeConversation: runtimeRemoveConversation, } = useConversationRuntime() - const { - folder, - newConversation, - conversations, - refreshConversations, - updateConversationLocal, - } = useFolderContext() + const { activeFolder: folder } = useActiveFolder() + const { conversations, refreshConversations, updateConversationLocal } = + useAppWorkspace() const { tabs, activeTabId, @@ -1050,6 +1048,13 @@ export function ConversationDetailPanel() { switchTab, onPreviewTabReplaced, } = useTabContext() + const newConversation = useMemo(() => { + const activeTab = tabs.find((tab) => tab.id === activeTabId) + if (!activeTab || activeTab.conversationId != null) return null + const workingDir = activeTab.workingDir ?? folder?.path + if (!workingDir) return null + return { workingDir, folderId: activeTab.folderId } + }, [tabs, activeTabId, folder?.path]) const { disconnect: disconnectByKey } = useAcpActions() const { addTask, updateTask } = useTaskContext() const [reloadByTabId, setReloadByTabId] = useState>({}) @@ -1298,7 +1303,7 @@ export function ConversationDetailPanel() { const handleNewConversation = useCallback(() => { if (!folder) return - openNewConversationTab(folder.path) + openNewConversationTab(folder.id, folder.path) }, [folder, openNewConversationTab]) const handleCloseActiveTab = useCallback(() => { @@ -1372,7 +1377,10 @@ export function ConversationDetailPanel() { if (!folder) return if (hasNoTabs) { - openNewConversationTab(newConversation?.workingDir ?? folder.path) + openNewConversationTab( + folder.id, + newConversation?.workingDir ?? folder.path + ) } }, [folder, hasNoTabs, newConversation?.workingDir, openNewConversationTab]) diff --git a/src/components/conversations/search-command-dialog.tsx b/src/components/conversations/search-command-dialog.tsx index 4cb4554..93cc8b7 100644 --- a/src/components/conversations/search-command-dialog.tsx +++ b/src/components/conversations/search-command-dialog.tsx @@ -6,10 +6,11 @@ import { enUS, zhCN, zhTW } from "date-fns/locale" import { File, Folder } from "lucide-react" import { useLocale, useTranslations } from "next-intl" import { useAuxPanelContext } from "@/contexts/aux-panel-context" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" +import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useTabContext } from "@/contexts/tab-context" import { useWorkspaceContext } from "@/contexts/workspace-context" -import { listFolderConversations } from "@/lib/api" +import { listAllConversations } from "@/lib/api" import type { AgentType, ConversationStatus, @@ -43,7 +44,16 @@ export function SearchCommandDialog({ const locale = useLocale() const dateFnsLocale = locale === "zh-CN" ? zhCN : locale === "zh-TW" ? zhTW : enUS - const { folderId, folder, conversations } = useFolderContext() + const { activeFolder: folder, activeFolderId } = useActiveFolder() + const { conversations: allConversations } = useAppWorkspace() + const folderId = activeFolderId ?? 0 + const conversations = useMemo( + () => + activeFolderId == null + ? [] + : allConversations.filter((c) => c.folder_id === activeFolderId), + [allConversations, activeFolderId] + ) const { openTab } = useTabContext() const { openFilePreview } = useWorkspaceContext() const { revealInFileTree } = useAuxPanelContext() @@ -96,8 +106,8 @@ export function SearchCommandDialog({ } setSearching(true) try { - const data = await listFolderConversations({ - folder_id: folderId, + const data = await listAllConversations({ + folder_ids: folderId > 0 ? [folderId] : null, search: q.trim() || null, agent_type: agent, }) @@ -136,7 +146,7 @@ export function SearchCommandDialog({ const handleSelectConversation = useCallback( (conv: DbConversationSummary) => { - openTab(conv.id, conv.agent_type, true) + openTab(conv.folder_id, conv.id, conv.agent_type, true) onOpenChange(false) }, [openTab, onOpenChange] diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index 86a1570..0203c74 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -13,8 +13,16 @@ import { import { useTranslations } from "next-intl" import { toast } from "sonner" import { Virtualizer, type VirtualizerHandle } from "virtua" -import { CheckCheck, ChevronRight, Download, Loader2, Plus } from "lucide-react" -import { useFolderContext } from "@/contexts/folder-context" +import { + CheckCheck, + ChevronRight, + Download, + Loader2, + Plus, + XCircle, +} from "lucide-react" +import { useActiveFolder } from "@/contexts/active-folder-context" +import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useTabContext } from "@/contexts/tab-context" import { useTaskContext } from "@/contexts/task-context" import { @@ -25,6 +33,11 @@ import { } from "@/lib/api" import type { ConversationStatus, DbConversationSummary } from "@/lib/types" import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types" +import { + loadFolderExpanded, + saveFolderExpanded, + type SidebarViewMode, +} from "@/lib/sidebar-view-mode-storage" import { SidebarConversationCard } from "./sidebar-conversation-card" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" @@ -69,12 +82,99 @@ function compareByUpdatedAtDesc( } type FlatItem = - | { type: "header"; status: ConversationStatus; count: number } + | { + type: "folder_header" + folderId: number + folderName: string + branch: string | null + count: number + expanded: boolean + } + | { + type: "status_header" + status: ConversationStatus + count: number + parentFolderId?: number + } | { type: "conversation"; conversation: DbConversationSummary } const CARD_HEIGHT = 62 -const GroupHeader = memo(function GroupHeader({ +const FolderHeader = memo(function FolderHeader({ + folderId, + folderName, + branch, + count, + expanded, + onToggle, + onFocus, + onCloseFolderTabs, + onRemoveFromWorkspace, + highlighted, + t, +}: { + folderId: number + folderName: string + branch: string | null + count: number + expanded: boolean + onToggle: (folderId: number) => void + onFocus: (folderId: number) => void + onCloseFolderTabs: (folderId: number) => void + onRemoveFromWorkspace: (folderId: number) => void + highlighted: boolean + t: ReturnType +}) { + return ( + + + + + + onFocus(folderId)}> + {t("folderHeaderMenu.focus")} + + onCloseFolderTabs(folderId)}> + {t("folderHeaderMenu.closeFolderTabs")} + + + onRemoveFromWorkspace(folderId)} + > + + {t("folderHeaderMenu.removeFromWorkspace")} + + + + ) +}) + +const StatusHeader = memo(function StatusHeader({ status, count, isOpen, @@ -168,32 +268,62 @@ export interface SidebarConversationListHandle { scrollToActive: () => void expandAll: () => void collapseAll: () => void + revealFolder: (folderId: number) => void +} + +export interface SidebarConversationListProps { + viewMode?: SidebarViewMode + searchQuery?: string } export function SidebarConversationList({ ref, -}: { + viewMode = "flat", + searchQuery = "", +}: SidebarConversationListProps & { ref?: Ref }) { const t = useTranslations("Folder.sidebar") const tStatus = useTranslations("Folder.statusLabels") const tCommon = useTranslations("Folder.common") const { - folder, + allFolders, conversations, - loading, - refreshing, - error, - selectedConversation, - folderId, + conversationsLoading: loading, + conversationsError: error, refreshConversations, updateConversationLocal, - } = useFolderContext() + branches, + removeFolderFromWorkspace, + } = useAppWorkspace() + const refreshing = loading + const { activeFolder } = useActiveFolder() - const { openTab, closeConversationTab, openNewConversationTab } = - useTabContext() + const { + openTab, + closeConversationTab, + closeTabsByFolder, + openNewConversationTab, + activeTabId, + tabs, + } = useTabContext() const { addTask, updateTask } = useTaskContext() + const folderIndex = useMemo(() => { + const map = new Map() + for (const f of allFolders) map.set(f.id, { name: f.name, path: f.path }) + return map + }, [allFolders]) + + const selectedConversation = useMemo(() => { + const activeTab = tabs.find((tab) => tab.id === activeTabId) + if (!activeTab || activeTab.conversationId == null) return null + return { + id: activeTab.conversationId, + agentType: activeTab.agentType, + } + }, [tabs, activeTabId]) + const [importing, setImporting] = useState(false) const [completeReviewOpen, setCompleteReviewOpen] = useState(false) const [completingReview, setCompletingReview] = useState(false) @@ -205,10 +335,160 @@ export function SidebarConversationList({ completed: false, cancelled: false, }) + const [folderExpanded, setFolderExpanded] = useState>( + {} + ) + const [highlightedFolder, setHighlightedFolder] = useState( + null + ) + const [removeConfirm, setRemoveConfirm] = useState<{ + folderId: number + folderName: string + } | null>(null) + + useEffect(() => { + // Hydrate from localStorage after mount to keep SSR/CSR markup consistent. + + setFolderExpanded(loadFolderExpanded()) + }, []) const scrollToActiveRef = useRef<() => void>(() => {}) const pendingScrollRef = useRef(false) const virtualizerRef = useRef(null) + const highlightTimerRef = useRef(null) + + const normalizedSearch = searchQuery.trim().toLowerCase() + const filteredConversations = useMemo(() => { + if (!normalizedSearch) return conversations + return conversations.filter((c) => { + const title = (c.title ?? "").toLowerCase() + return title.includes(normalizedSearch) + }) + }, [conversations, normalizedSearch]) + + const byStatus = useMemo(() => { + const map = new Map() + for (const conv of filteredConversations) { + const status = conv.status as ConversationStatus + const list = map.get(status) + if (list) list.push(conv) + else map.set(status, [conv]) + } + for (const list of map.values()) list.sort(compareByUpdatedAtDesc) + return map + }, [filteredConversations]) + + const byFolder = useMemo(() => { + const map = new Map< + number, + Map + >() + for (const conv of filteredConversations) { + const folderId = conv.folder_id + let inner = map.get(folderId) + if (!inner) { + inner = new Map() + map.set(folderId, inner) + } + const status = conv.status as ConversationStatus + const list = inner.get(status) + if (list) list.push(conv) + else inner.set(status, [conv]) + } + for (const inner of map.values()) { + for (const list of inner.values()) list.sort(compareByUpdatedAtDesc) + } + return map + }, [filteredConversations]) + + const orderedFolderIds = useMemo(() => { + // Show every folder in the workspace DB, even ones without conversations. + // Folders that only have orphan conversations still appear via byFolder. + const seen = new Set() + const ids: number[] = [] + for (const f of allFolders) { + if (!seen.has(f.id)) { + seen.add(f.id) + ids.push(f.id) + } + } + for (const id of byFolder.keys()) { + if (!seen.has(id)) { + seen.add(id) + ids.push(id) + } + } + return ids + }, [allFolders, byFolder]) + + const flatItems = useMemo(() => { + const items: FlatItem[] = [] + if (viewMode === "grouped") { + for (const folderId of orderedFolderIds) { + const inner = byFolder.get(folderId) + const totalCount = inner + ? Array.from(inner.values()).reduce( + (sum, list) => sum + list.length, + 0 + ) + : 0 + const folderName = folderIndex.get(folderId)?.name ?? String(folderId) + const branch = branches.get(folderId) ?? null + const expanded = folderExpanded[folderId] ?? true + items.push({ + type: "folder_header", + folderId, + folderName, + branch, + count: totalCount, + expanded, + }) + if (!expanded || !inner) continue + for (const status of STATUS_ORDER) { + const list = inner.get(status) + if (!list || list.length === 0) continue + items.push({ + type: "status_header", + status, + count: list.length, + parentFolderId: folderId, + }) + if (groupExpanded[status]) { + for (const conv of list) { + items.push({ type: "conversation", conversation: conv }) + } + } + } + } + } else { + for (const status of STATUS_ORDER) { + const list = byStatus.get(status) + if (!list || list.length === 0) continue + items.push({ type: "status_header", status, count: list.length }) + if (groupExpanded[status]) { + for (const conv of list) { + items.push({ type: "conversation", conversation: conv }) + } + } + } + } + return items + }, [ + viewMode, + orderedFolderIds, + byFolder, + folderIndex, + branches, + folderExpanded, + byStatus, + groupExpanded, + ]) + + const reviewConversations = useMemo( + () => byStatus.get("pending_review") ?? [], + [byStatus] + ) + const reviewConversationCount = reviewConversations.length useImperativeHandle(ref, () => ({ scrollToActive() { @@ -230,45 +510,42 @@ export function SidebarConversationList({ cancelled: false, }) }, + revealFolder(folderId: number) { + setFolderExpanded((prev) => { + if (prev[folderId] === true) return prev + const next = { ...prev, [folderId]: true } + saveFolderExpanded(next) + return next + }) + setHighlightedFolder(folderId) + if (highlightTimerRef.current) { + window.clearTimeout(highlightTimerRef.current) + } + highlightTimerRef.current = window.setTimeout(() => { + setHighlightedFolder(null) + highlightTimerRef.current = null + }, 1200) + requestAnimationFrame(() => { + const idx = flatItems.findIndex( + (item) => item.type === "folder_header" && item.folderId === folderId + ) + if (idx >= 0) { + virtualizerRef.current?.scrollToIndex(idx, { + align: "start", + smooth: true, + }) + } + }) + }, })) - const grouped = useMemo(() => { - const map = new Map() - for (const conv of conversations) { - const status = conv.status as ConversationStatus - const list = map.get(status) - if (list) { - list.push(conv) - } else { - map.set(status, [conv]) + useEffect(() => { + return () => { + if (highlightTimerRef.current) { + window.clearTimeout(highlightTimerRef.current) } } - for (const list of map.values()) { - list.sort(compareByUpdatedAtDesc) - } - return map - }, [conversations]) - - const flatItems = useMemo(() => { - const items: FlatItem[] = [] - for (const status of STATUS_ORDER) { - const list = grouped.get(status) - if (!list || list.length === 0) continue - items.push({ type: "header", status, count: list.length }) - if (groupExpanded[status]) { - for (const conv of list) { - items.push({ type: "conversation", conversation: conv }) - } - } - } - return items - }, [grouped, groupExpanded]) - - const reviewConversations = useMemo( - () => grouped.get("pending_review") ?? [], - [grouped] - ) - const reviewConversationCount = reviewConversations.length + }, []) useEffect(() => { scrollToActiveRef.current = () => { @@ -285,6 +562,15 @@ export function SidebarConversationList({ pendingScrollRef.current = true return } + if (viewMode === "grouped" && !(folderExpanded[conv.folder_id] ?? true)) { + setFolderExpanded((prev) => { + const next = { ...prev, [conv.folder_id]: true } + saveFolderExpanded(next) + return next + }) + pendingScrollRef.current = true + return + } const index = flatItems.findIndex( (item) => item.type === "conversation" && @@ -303,12 +589,72 @@ export function SidebarConversationList({ pendingScrollRef.current = false scrollToActiveRef.current() } - }, [selectedConversation, flatItems, conversations, groupExpanded]) + }, [ + selectedConversation, + flatItems, + conversations, + groupExpanded, + folderExpanded, + viewMode, + ]) const toggleGroup = useCallback((status: ConversationStatus) => { setGroupExpanded((prev) => ({ ...prev, [status]: !prev[status] })) }, []) + const toggleFolder = useCallback((folderId: number) => { + setFolderExpanded((prev) => { + const next = { ...prev, [folderId]: !(prev[folderId] ?? true) } + saveFolderExpanded(next) + return next + }) + }, []) + + const focusFolder = useCallback( + (folderId: number) => { + const idx = flatItems.findIndex( + (item) => item.type === "folder_header" && item.folderId === folderId + ) + if (idx >= 0) { + virtualizerRef.current?.scrollToIndex(idx, { + align: "start", + smooth: true, + }) + } + }, + [flatItems] + ) + + const handleCloseFolderTabs = useCallback( + (folderId: number) => { + closeTabsByFolder(folderId) + }, + [closeTabsByFolder] + ) + + const handleRemoveFolder = useCallback( + (folderId: number) => { + const name = folderIndex.get(folderId)?.name ?? String(folderId) + setRemoveConfirm({ folderId, folderName: name }) + }, + [folderIndex] + ) + + const handleRemoveFolderConfirm = useCallback(async () => { + if (!removeConfirm) return + const { folderId, folderName } = removeConfirm + try { + closeTabsByFolder(folderId) + await removeFolderFromWorkspace(folderId) + toast.success(t("toasts.folderRemoved", { name: folderName })) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + toast.error(t("toasts.removeFolderFailed", { message: msg })) + } finally { + setRemoveConfirm(null) + } + }, [removeConfirm, closeTabsByFolder, removeFolderFromWorkspace, t]) + const handleOpenCompleteReview = useCallback( () => setCompleteReviewOpen(true), [] @@ -316,16 +662,34 @@ export function SidebarConversationList({ const handleSelect = useCallback( (id: number, agentType: string) => { - openTab(id, agentType as Parameters[1], false) + const conv = conversations.find( + (c) => c.id === id && c.agent_type === agentType + ) + if (!conv) return + openTab( + conv.folder_id, + id, + agentType as Parameters[2], + false + ) }, - [openTab] + [openTab, conversations] ) const handleDoubleClick = useCallback( (id: number, agentType: string) => { - openTab(id, agentType as Parameters[1], true) + const conv = conversations.find( + (c) => c.id === id && c.agent_type === agentType + ) + if (!conv) return + openTab( + conv.folder_id, + id, + agentType as Parameters[2], + true + ) }, - [openTab] + [openTab, conversations] ) const handleRename = useCallback( @@ -338,11 +702,20 @@ export function SidebarConversationList({ const handleDelete = useCallback( async (id: number, agentType: string) => { + const conv = conversations.find( + (c) => c.id === id && c.agent_type === agentType + ) await deleteConversation(id) - closeConversationTab(id, agentType as Parameters[1]) + if (conv) { + closeConversationTab( + conv.folder_id, + id, + agentType as Parameters[2] + ) + } refreshConversations() }, - [closeConversationTab, refreshConversations] + [closeConversationTab, refreshConversations, conversations] ) const handleStatusChange = useCallback( @@ -354,18 +727,19 @@ export function SidebarConversationList({ ) const handleNewConversation = useCallback(() => { - if (!folder) return - openNewConversationTab(folder.path) - }, [folder, openNewConversationTab]) + if (!activeFolder) return + openNewConversationTab(activeFolder.id, activeFolder.path) + }, [activeFolder, openNewConversationTab]) const handleImport = useCallback(async () => { if (importing) return + if (!activeFolder) return setImporting(true) - const taskId = `import-${folderId}-${Date.now()}` + const taskId = `import-${activeFolder.id}-${Date.now()}` addTask(taskId, t("importLocalSessions")) updateTask(taskId, { status: "running" }) try { - const result = await importLocalConversations(folderId) + const result = await importLocalConversations(activeFolder.id) updateTask(taskId, { status: "completed" }) refreshConversations() if (result.imported > 0) { @@ -385,13 +759,12 @@ export function SidebarConversationList({ } finally { setImporting(false) } - }, [importing, folderId, addTask, updateTask, refreshConversations, t]) + }, [importing, activeFolder, addTask, updateTask, refreshConversations, t]) const handleCompleteAllReview = useCallback(async () => { if (completingReview || reviewConversationCount === 0) return setCompletingReview(true) try { - // Optimistic: update all locally first for (const conversation of reviewConversations) { updateConversationLocal(conversation.id, { status: "completed" }) } @@ -407,7 +780,6 @@ export function SidebarConversationList({ } catch (e) { const msg = e instanceof Error ? e.message : String(e) toast.error(t("toasts.completeReviewFailed", { message: msg })) - // Revert on error — refetch from server refreshConversations() } finally { setCompletingReview(false) @@ -421,6 +793,9 @@ export function SidebarConversationList({ t, ]) + const emptyAfterSearch = + filteredConversations.length === 0 && normalizedSearch.length > 0 + return (
{(loading || refreshing) && ( @@ -451,7 +826,7 @@ export function SidebarConversationList({
- + {t("newConversation")} - + {importing ? t("importing") : t("importLocalSessions")} + ) : emptyAfterSearch ? ( +
+

+ {t("noMatchingConversations")} +

+
) : ( @@ -483,53 +870,87 @@ export function SidebarConversationList({ className={cn("h-full min-h-0 px-2", "[overflow-anchor:none]")} > - {flatItems.map((item) => { - const key = - item.type === "header" - ? `header-${item.status}` - : `conv-${item.conversation.id}` - return ( -
- {item.type === "header" ? ( - item.status === "pending_review" ? ( - - ) : ( - - ) - ) : ( - { + if (item.type === "folder_header") { + return ( + + ) + } + const indented = + viewMode === "grouped" && + (item.type === "status_header" + ? item.parentFolderId != null + : true) + if (item.type === "status_header") { + const key = `status-${item.parentFolderId ?? "root"}-${item.status}-${index}` + const headerNode = + item.status === "pending_review" ? ( + - )} + ) : ( + + ) + return indented ? ( +
+ {headerNode} +
+ ) : ( + headerNode + ) + } + const conv = item.conversation + const cardNode = ( + + ) + return indented ? ( +
+ {cardNode}
+ ) : ( +
{cardNode}
) })} @@ -537,12 +958,18 @@ export function SidebarConversationList({
- + {t("newConversation")} - + {importing ? t("importing") : t("importLocalSessions")} @@ -577,6 +1004,28 @@ export function SidebarConversationList({ + + !open && setRemoveConfirm(null)} + > + + + {t("removeFolderConfirmTitle")} + + {t("removeFolderConfirmDescription", { + name: removeConfirm?.folderName ?? "", + })} + + + + {tCommon("cancel")} + + {tCommon("confirm")} + + + +
) } diff --git a/src/components/diff/unified-diff-preview.tsx b/src/components/diff/unified-diff-preview.tsx index 4a38478..af7ba62 100644 --- a/src/components/diff/unified-diff-preview.tsx +++ b/src/components/diff/unified-diff-preview.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react" import { useTranslations } from "next-intl" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" import { cn } from "@/lib/utils" import { ScrollArea } from "@/components/ui/scroll-area" @@ -529,7 +529,7 @@ export function UnifiedDiffPreview({ className?: string }) { const t = useTranslations("Folder.diffPreview") - const { folder } = useFolderContext() + const { activeFolder: folder } = useActiveFolder() const files = useMemo(() => parseUnifiedDiff(diffText), [diffText]) if (!diffText.trim()) { diff --git a/src/components/files/file-workspace-panel.tsx b/src/components/files/file-workspace-panel.tsx index f5fe5eb..1130f8a 100644 --- a/src/components/files/file-workspace-panel.tsx +++ b/src/components/files/file-workspace-panel.tsx @@ -5,7 +5,7 @@ import dynamic from "next/dynamic" import { ChevronDown, ChevronRight, FileCode2, FileIcon } from "lucide-react" import type { editor as MonacoEditorNs } from "monaco-editor" import { useTranslations } from "next-intl" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { ImagePreview } from "@/components/files/image-preview" import { DiffViewer } from "@/components/diff/diff-viewer" @@ -766,7 +766,7 @@ export function FileWorkspacePanel() { saveActiveFile, updateActiveFileContent, } = useWorkspaceContext() - const { folder } = useFolderContext() + const { activeFolder: folder } = useActiveFolder() const folderPath = folder?.path ?? null const activeScope = activeFileTab?.id ?? "__default__" const editorRef = useRef(null) diff --git a/src/components/files/file-workspace-tab-bar.tsx b/src/components/files/file-workspace-tab-bar.tsx index 3af9dbb..deed2a4 100644 --- a/src/components/files/file-workspace-tab-bar.tsx +++ b/src/components/files/file-workspace-tab-bar.tsx @@ -5,7 +5,7 @@ import { Reorder } from "motion/react" import { Code, Eye, ExternalLink, FileText, GitCompare, X } from "lucide-react" import { useTranslations } from "next-intl" import { openPath } from "@/lib/platform" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" @@ -33,7 +33,7 @@ export function FileWorkspaceTabBar() { previewFileTabIds, toggleFileTabPreview, } = useWorkspaceContext() - const { folder } = useFolderContext() + const { activeFolder: folder } = useActiveFolder() const { shortcuts } = useShortcutSettings() const scrollRef = useRef(null) const [isHovered, setIsHovered] = useState(false) diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index a35ed1e..7213efb 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -13,7 +13,7 @@ import ignore from "ignore" import { Check, ChevronRight } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useTabContext } from "@/contexts/tab-context" import { useTerminalContext } from "@/contexts/terminal-context" @@ -797,7 +797,7 @@ export function FileTreeTab() { const t = useTranslations("Folder.fileTreeTab") const tCommon = useTranslations("Folder.common") const { pendingRevealPath, consumePendingRevealPath } = useAuxPanelContext() - const { folder } = useFolderContext() + const { activeFolder: folder } = useActiveFolder() const { tabs, activeTabId } = useTabContext() const { createTerminalInDirectory } = useTerminalContext() const { diff --git a/src/components/layout/aux-panel-git-changes-tab.tsx b/src/components/layout/aux-panel-git-changes-tab.tsx index 3e078fa..7030310 100644 --- a/src/components/layout/aux-panel-git-changes-tab.tsx +++ b/src/components/layout/aux-panel-git-changes-tab.tsx @@ -34,7 +34,7 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu" import { Skeleton } from "@/components/ui/skeleton" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" import { useTabContext } from "@/contexts/tab-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store" @@ -378,7 +378,7 @@ export function GitChangesTab() { const t = useTranslations("Folder.gitChangesTab") const tCommon = useTranslations("Folder.common") const tFileTree = useTranslations("Folder.fileTreeTab") - const { folder } = useFolderContext() + const { activeFolder: folder } = useActiveFolder() const { tabs, activeTabId } = useTabContext() const { openFilePreview, openWorkingTreeDiff } = useWorkspaceContext() const workspaceState = useWorkspaceStateStore(folder?.path ?? null) diff --git a/src/components/layout/aux-panel-git-log-tab.tsx b/src/components/layout/aux-panel-git-log-tab.tsx index 53caf0e..079ba45 100644 --- a/src/components/layout/aux-panel-git-log-tab.tsx +++ b/src/components/layout/aux-panel-git-log-tab.tsx @@ -77,7 +77,7 @@ import { } from "@/components/ui/collapsible" import { Skeleton } from "@/components/ui/skeleton" import { subscribe } from "@/lib/platform" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store" import { @@ -691,7 +691,7 @@ function BranchSelector({ export function GitLogTab() { const t = useTranslations("Folder.gitLogTab") const tCommon = useTranslations("Folder.common") - const { folder } = useFolderContext() + const { activeFolder: folder } = useActiveFolder() const { openCommitDiff, openFilePreview } = useWorkspaceContext() const workspaceState = useWorkspaceStateStore(folder?.path ?? null) const isGitRepo = workspaceState.isGitRepo diff --git a/src/components/layout/aux-panel-session-files-tab.tsx b/src/components/layout/aux-panel-session-files-tab.tsx index a3a2bc3..3704086 100644 --- a/src/components/layout/aux-panel-session-files-tab.tsx +++ b/src/components/layout/aux-panel-session-files-tab.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from "react" import { ChevronRight, FileIcon } from "lucide-react" import { useTranslations } from "next-intl" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" import { useTabContext } from "@/contexts/tab-context" import { useConversationRuntime } from "@/contexts/conversation-runtime-context" import { useWorkspaceContext } from "@/contexts/workspace-context" @@ -58,7 +58,7 @@ function SessionFilesContent({ conversationId }: { conversationId: number }) { const { loading } = useConversationDetail(conversationId) const { getTimelineTurns } = useConversationRuntime() const { openSessionFileDiff } = useWorkspaceContext() - const { folder } = useFolderContext() + const { activeFolder: folder } = useActiveFolder() const [openGroups, setOpenGroups] = useState>({}) const turns = useMemo( diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx deleted file mode 100644 index 61f0a07..0000000 --- a/src/components/layout/branch-dropdown.tsx +++ /dev/null @@ -1,977 +0,0 @@ -"use client" - -import { useState, useRef, useCallback, useMemo, useEffect } from "react" -const emitEvent = async (event: string, payload?: unknown) => { - try { - const { emit } = await import("@tauri-apps/api/event") - await emit(event, payload) - } catch { - /* not in Tauri */ - } -} -import { openFileDialog, subscribe } from "@/lib/platform" -import { - GitBranch, - ChevronDown, - ChevronRight, - ArrowDownToLine, - Upload, - GitBranchPlus, - GitCommitHorizontal, - Archive, - ArchiveRestore, - GitFork, - GitMerge, - GitPullRequestArrow, - Trash2, - Loader2, - RefreshCw, - FolderGit2, - FolderOpen, - ArrowLeftRight, - Globe, -} from "lucide-react" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible" -import { ScrollArea } from "@/components/ui/scroll-area" -import { useTranslations } from "next-intl" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { - gitInit, - gitPull, - gitFetch, - gitNewBranch, - gitWorktreeAdd, - gitCheckout, - gitListAllBranches, - gitMerge, - gitRebase, - gitDeleteBranch, - gitDeleteRemoteBranch, - openFolderWindow, - openCommitWindow, - setFolderParentBranch, - openStashWindow, - openPushWindow, -} from "@/lib/api" -import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog" -import { ConflictDialog } from "@/components/layout/conflict-dialog" -import { StashDialog } from "@/components/layout/stash-dialog" -import { toErrorMessage } from "@/lib/app-error" -import type { GitBranchList, GitConflictInfo } from "@/lib/types" -import { toast } from "sonner" -import { useFolderContext } from "@/contexts/folder-context" -import { useTaskContext } from "@/contexts/task-context" -import { useAlertContext } from "@/contexts/alert-context" -import { useGitCredential } from "@/contexts/git-credential-context" - -interface BranchDropdownProps { - branch: string | null - parentBranch: string | null - onBranchChange: () => void -} - -type ConfirmAction = { - type: "merge" | "rebase" | "delete" | "forceDelete" | "deleteRemote" - branchName: string -} - -interface GitCommitSucceededEventPayload { - folder_id: number - committed_files: number -} - -interface GitPushSucceededEventPayload { - folder_id: number - pushed_commits: number - upstream_set: boolean -} - -export function BranchDropdown({ - branch, - parentBranch, - onBranchChange, -}: BranchDropdownProps) { - const t = useTranslations("Folder.branchDropdown") - const tCommon = useTranslations("Folder.common") - const { folder } = useFolderContext() - const folderPath = folder?.path ?? "" - const { addTask, updateTask, removeTask } = useTaskContext() - const { pushAlert } = useAlertContext() - const { withCredentialRetry } = useGitCredential() - const [branchList, setBranchList] = useState({ - local: [], - remote: [], - worktree_branches: [], - }) - const [newBranchOpen, setNewBranchOpen] = useState(false) - const [newBranchName, setNewBranchName] = useState("") - const [loading, setLoading] = useState(false) - const [dropdownOpen, setDropdownOpen] = useState(false) - const [branchLoading, setBranchLoading] = useState(false) - const [localOpen, setLocalOpen] = useState(false) - const [remoteOpen, setRemoteOpen] = useState(false) - const [confirmAction, setConfirmAction] = useState(null) - const [worktreeOpen, setWorktreeOpen] = useState(false) - const [worktreeBranchName, setWorktreeBranchName] = useState("") - const [worktreePath, setWorktreePath] = useState("") - const [manageRemotesOpen, setManageRemotesOpen] = useState(false) - const [stashDialogOpen, setStashDialogOpen] = useState(false) - const [conflictInfo, setConflictInfo] = useState(null) - const taskSeq = useRef(0) - const worktreeBranchSet = useMemo( - () => new Set(branchList.worktree_branches), - [branchList.worktree_branches] - ) - const groupedRemoteBranches = useMemo(() => { - const groups: Record = {} - for (const b of branchList.remote) { - const slashIndex = b.indexOf("/") - const remoteName = slashIndex > 0 ? b.substring(0, slashIndex) : "origin" - if (!groups[remoteName]) groups[remoteName] = [] - groups[remoteName].push(b) - } - return groups - }, [branchList.remote]) - const remoteNames = Object.keys(groupedRemoteBranches) - const hasMultipleRemotes = remoteNames.length > 1 - - useEffect(() => { - if (!folder) return - - let unlisten: (() => void) | null = null - - subscribe( - "folder://git-commit-succeeded", - (payload) => { - if (payload.folder_id !== folder.id) return - toast.success(t("toasts.commitCodeCompleted"), { - description: t("toasts.committedFiles", { - count: payload.committed_files, - }), - }) - onBranchChange() - } - ) - .then((fn) => { - unlisten = fn - }) - .catch((err) => { - console.error("[BranchDropdown] failed to listen commit event:", err) - }) - - return () => { - unlisten?.() - } - }, [folder, onBranchChange, t]) - - useEffect(() => { - if (!folder) return - - let unlisten: (() => void) | null = null - - subscribe( - "folder://git-push-succeeded", - (payload) => { - if (payload.folder_id !== folder.id) return - const { pushed_commits, upstream_set } = payload - let description: string - if (upstream_set) { - description = - pushed_commits === 0 - ? t("toasts.upstreamSet") - : t("toasts.upstreamSetAndPushed", { count: pushed_commits }) - } else if (pushed_commits === 0) { - description = t("toasts.noCommitsToPush") - } else { - description = t("toasts.pushedCommits", { count: pushed_commits }) - } - toast.success(t("toasts.pushCodeCompleted"), { description }) - onBranchChange() - } - ) - .then((fn) => { - unlisten = fn - }) - .catch((err) => { - console.error("[BranchDropdown] failed to listen push event:", err) - }) - - return () => { - unlisten?.() - } - }, [folder, onBranchChange, t]) - - async function runGitTask( - label: string, - action: () => Promise, - getSuccessDescription?: (result: T) => string | false | undefined, - onError?: (errorMsg: string) => boolean - ) { - const taskId = `git-${++taskSeq.current}-${Date.now()}` - setLoading(true) - addTask(taskId, label) - updateTask(taskId, { status: "running" }) - try { - const result = await action() - const successDescription = getSuccessDescription?.(result) - updateTask(taskId, { status: "completed" }) - onBranchChange() - void emitEvent("folder://git-branch-changed", { - folder_id: folder?.id, - }) - if (successDescription !== false) { - toast.success( - t("toasts.taskCompleted", { label }), - successDescription - ? { - description: successDescription, - } - : undefined - ) - } - } catch (err) { - removeTask(taskId) - const errorMsg = toErrorMessage(err) - if (onError?.(errorMsg)) { - return - } - const errorTitle = t("toasts.taskFailed", { label }) - pushAlert("error", errorTitle, errorMsg) - toast.error(errorTitle, { description: errorMsg }) - } finally { - setLoading(false) - } - } - - const loadAllBranches = useCallback(async () => { - setBranchLoading(true) - try { - const list = await gitListAllBranches(folderPath) - setBranchList(list) - } catch { - setBranchList({ local: [], remote: [], worktree_branches: [] }) - } finally { - setBranchLoading(false) - } - }, [folderPath]) - - function handleDropdownOpenChange(open: boolean) { - setDropdownOpen(open) - if (open && branch !== null) { - loadAllBranches() - } - if (!open) { - setLocalOpen(false) - setRemoteOpen(false) - } - } - - async function handleNewBranch() { - const name = newBranchName.trim() - if (!name) return - setNewBranchOpen(false) - setNewBranchName("") - await runGitTask(t("tasks.newBranch", { name }), () => - gitNewBranch(folderPath, name) - ) - } - - function handleOpenWorktreeDialog() { - const chars = "abcdefghijklmnopqrstuvwxyz0123456789" - let random = "" - for (let i = 0; i < 6; i++) { - random += chars[Math.floor(Math.random() * chars.length)] - } - const folderName = folderPath.split("/").filter(Boolean).pop() ?? "project" - const currentBranch = branch ?? "main" - const defaultBranch = `cv-${currentBranch}-${random}` - const parentDir = folderPath.substring(0, folderPath.lastIndexOf("/")) - setWorktreeBranchName(defaultBranch) - setWorktreePath(`${parentDir}/${folderName}-${currentBranch}-${random}`) - setWorktreeOpen(true) - } - - function handleWorktreeBranchChange(name: string) { - setWorktreeBranchName(name) - } - - async function handleBrowseWorktreePath() { - const selected = await openFileDialog({ directory: true, multiple: false }) - if (selected) { - setWorktreePath(Array.isArray(selected) ? selected[0] : selected) - } - } - - async function handleNewWorktree() { - const name = worktreeBranchName.trim() - const wtPath = worktreePath.trim() - if (!name || !wtPath) return - setWorktreeOpen(false) - await runGitTask(t("tasks.newWorktree", { name }), async () => { - await gitWorktreeAdd(folderPath, name, wtPath) - await openFolderWindow(wtPath, { newWindow: true }) - await setFolderParentBranch(wtPath, branch) - }) - } - - function handleMergeParent() { - if (!parentBranch) return - setConfirmAction({ type: "merge", branchName: parentBranch }) - } - - async function handleCheckout(branchName: string) { - setDropdownOpen(false) - await runGitTask(t("tasks.checkoutTo", { branchName }), () => - gitCheckout(folderPath, branchName) - ) - } - - async function handleCheckoutRemote(remoteBranch: string) { - const localName = remoteBranch.replace(/^[^/]+\//, "") - setDropdownOpen(false) - await runGitTask(t("tasks.checkoutTo", { branchName: localName }), () => - gitCheckout(folderPath, localName) - ) - } - - async function handleConfirm() { - if (!confirmAction) return - const { type, branchName } = confirmAction - setConfirmAction(null) - - switch (type) { - case "merge": - await runGitTask( - t("tasks.mergeBranch", { branchName }), - () => gitMerge(folderPath, branchName), - (result) => { - if (result.conflict?.has_conflicts) { - setConflictInfo(result.conflict) - return false - } - if (result.merged_commits === 0) { - return t("toasts.mergeNoNewCommits", { branchName }) - } - return t("toasts.mergedCommits", { count: result.merged_commits }) - } - ) - break - case "rebase": - await runGitTask( - t("tasks.rebaseTo", { branchName }), - () => gitRebase(folderPath, branchName), - (result) => { - if (result.conflict?.has_conflicts) { - setConflictInfo(result.conflict) - return false - } - return undefined - } - ) - break - case "delete": - await runGitTask( - t("tasks.deleteBranch", { branchName }), - () => gitDeleteBranch(folderPath, branchName), - undefined, - (errorMsg) => { - if (/not fully merged/i.test(errorMsg)) { - setConfirmAction({ type: "forceDelete", branchName }) - return true - } - return false - } - ) - break - case "forceDelete": - await runGitTask(t("tasks.deleteBranch", { branchName }), () => - gitDeleteBranch(folderPath, branchName, true) - ) - break - case "deleteRemote": { - const idx = branchName.indexOf("/") - const remote = branchName.substring(0, idx) - const rb = branchName.substring(idx + 1) - await runGitTask(t("tasks.deleteRemoteBranch", { branchName }), () => - withCredentialRetry( - (creds) => gitDeleteRemoteBranch(folderPath, remote, rb, creds), - { folderPath } - ) - ) - break - } - } - } - - function getConfirmTitle() { - if (!confirmAction) return "" - switch (confirmAction.type) { - case "merge": - return t("confirm.mergeTitle") - case "rebase": - return t("confirm.rebaseTitle") - case "delete": - return t("confirm.deleteTitle") - case "forceDelete": - return t("confirm.forceDeleteTitle") - case "deleteRemote": - return t("confirm.deleteRemoteTitle") - } - } - - function getConfirmDescription() { - if (!confirmAction) return "" - switch (confirmAction.type) { - case "merge": - return t("confirm.mergeDescription", { - branchName: confirmAction.branchName, - currentBranch: branch ?? "-", - }) - case "rebase": - return t("confirm.rebaseDescription", { - currentBranch: branch ?? "-", - branchName: confirmAction.branchName, - }) - case "delete": - return t("confirm.deleteDescription", { - branchName: confirmAction.branchName, - }) - case "forceDelete": - return t("confirm.forceDeleteDescription", { - branchName: confirmAction.branchName, - }) - case "deleteRemote": - return t("confirm.deleteRemoteDescription", { - branchName: confirmAction.branchName, - }) - } - } - - function renderBranchItem( - b: string, - isRemote: boolean, - displayName?: string - ) { - const label = displayName ?? b - const isCurrent = b === branch - const isTrackingCurrent = - isRemote && !!branch && b.replace(/^[^/]+\//, "") === branch - const isWorktree = worktreeBranchSet.has( - isRemote ? b.replace(/^[^/]+\//, "") : b - ) - const BranchIcon = isWorktree ? FolderGit2 : GitBranch - - if (isCurrent) { - return ( -
- - {label} - {t("current")} -
- ) - } - - return ( - - - - {label} - - - { - if (isRemote) { - handleCheckoutRemote(b) - } else { - handleCheckout(b) - } - }} - > - - {t("switchToBranch")} - - { - setDropdownOpen(false) - setConfirmAction({ type: "merge", branchName: b }) - }} - > - - {t("mergeBranchIntoCurrent", { - branchName: b, - currentBranch: branch ?? "-", - })} - - { - setDropdownOpen(false) - setConfirmAction({ type: "rebase", branchName: b }) - }} - > - - {t("rebaseCurrentToBranch", { - currentBranch: branch ?? "-", - branchName: b, - })} - - {!isTrackingCurrent && ( - <> - - { - setDropdownOpen(false) - setConfirmAction({ - type: isRemote ? "deleteRemote" : "delete", - branchName: b, - }) - }} - > - - {t("deleteBranch")} - - - )} - - - ) - } - - if (branch === null) { - return ( - - - - - - - runGitTask(t("tasks.initGitRepo"), () => gitInit(folderPath)) - } - > - - {t("initGitRepo")} - - - - ) - } - - return ( - <> - - - - - - - - runGitTask( - t("tasks.pullCode"), - () => - withCredentialRetry((creds) => gitPull(folderPath, creds), { - folderPath, - }), - (result) => { - if (result.conflict?.has_conflicts) { - setConflictInfo(result.conflict) - return false - } - if (result.updated_files === 0) { - return t("toasts.allFilesUpToDate") - } - return t("toasts.updatedFiles", { - count: result.updated_files, - }) - } - ) - } - > - - {t("pullCode")} - - - runGitTask(t("tasks.fetchInfo"), () => - withCredentialRetry((creds) => gitFetch(folderPath, creds), { - folderPath, - }) - ) - } - > - - {t("fetchRemoteBranches")} - - - - - { - if (!folder) return - setDropdownOpen(false) - openCommitWindow(folder.id).catch((err) => { - const title = t("toasts.openCommitWindowFailed") - const msg = toErrorMessage(err) - pushAlert("error", title, msg) - toast.error(title, { description: msg }) - }) - }} - > - - {t("openCommitWindow")} - - { - if (!folder) return - setDropdownOpen(false) - openPushWindow(folder.id).catch((err) => { - const title = t("toasts.openPushWindowFailed") - const msg = toErrorMessage(err) - pushAlert("error", title, msg) - toast.error(title, { description: msg }) - }) - }} - > - - {t("pushCode")} - - - - - { - setNewBranchName("") - setNewBranchOpen(true) - }} - > - - {t("newBranch")} - - - - {t("newWorktree")} - - - - - { - setDropdownOpen(false) - setStashDialogOpen(true) - }} - > - - {t("stashChanges")} - - { - if (!folder) return - openStashWindow(folder.id).catch((err) => { - const msg = toErrorMessage(err) - pushAlert("error", t("stashPop"), msg) - }) - }} - > - - {t("stashPop")} - - - - - { - setDropdownOpen(false) - setManageRemotesOpen(true) - }} - > - - {t("manageRemotes")} - - - - {branchLoading ? ( -
- -
- ) : ( - - - - - {t("localBranches", { count: branchList.local.length })} - - - {branchList.local.length === 0 ? ( - - {t("noLocalBranches")} - - ) : ( - branchList.local.map((b) => renderBranchItem(b, false)) - )} - - - - - - - {t("remoteBranches", { count: branchList.remote.length })} - - - {branchList.remote.length === 0 ? ( - - {t("noRemoteBranches")} - - ) : hasMultipleRemotes ? ( - remoteNames.map((remoteName) => ( - - - - {remoteName} ( - {groupedRemoteBranches[remoteName].length}) - - - {groupedRemoteBranches[remoteName].map((b) => - renderBranchItem( - b, - true, - b.substring(remoteName.length + 1) - ) - )} - - - )) - ) : ( - branchList.remote.map((b) => { - const slashIndex = b.indexOf("/") - const shortName = - slashIndex > 0 ? b.substring(slashIndex + 1) : b - return renderBranchItem(b, true, shortName) - }) - )} - - - - )} -
-
- - {parentBranch && ( - - )} - - { - if (!open) setConfirmAction(null) - }} - > - - - {getConfirmTitle()} - - {getConfirmDescription()} - - - - {tCommon("cancel")} - - {tCommon("confirm")} - - - - - - - - - {t("dialogs.newBranchTitle")} - - {t("dialogs.newBranchDescription", { branch: branch ?? "-" })} - - - setNewBranchName(e.target.value)} - onKeyDown={(e) => { - if (e.nativeEvent.isComposing || e.key === "Process") return - if (e.key === "Enter") handleNewBranch() - }} - autoFocus - /> - - - - - - - - - - - {t("dialogs.newWorktreeTitle")} - - {t("dialogs.newWorktreeDescription", { branch: branch ?? "-" })} - - -
-
- - handleWorktreeBranchChange(e.target.value)} - onKeyDown={(e) => { - if (e.nativeEvent.isComposing || e.key === "Process") return - if (e.key === "Enter") handleNewWorktree() - }} - autoFocus - /> -
-
- -
- setWorktreePath(e.target.value)} - className="flex-1" - /> - -
-
-
- - - - -
-
- - loadAllBranches()} - /> - - setConflictInfo(null)} - onResolved={onBranchChange} - /> - - setStashDialogOpen(false)} - onStashed={onBranchChange} - /> - - ) -} diff --git a/src/components/layout/command-dropdown.tsx b/src/components/layout/command-dropdown.tsx index 1ff3448..5448cd3 100644 --- a/src/components/layout/command-dropdown.tsx +++ b/src/components/layout/command-dropdown.tsx @@ -11,7 +11,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" import { useTerminalContext } from "@/contexts/terminal-context" import { bootstrapFolderCommandsFromPackageJson, @@ -40,7 +40,7 @@ function setSelectedCommandId(folderId: number, cmdId: number) { export function CommandDropdown() { const t = useTranslations("Folder.commandDropdown") - const { folder } = useFolderContext() + const { activeFolder: folder } = useActiveFolder() const { createTerminalWithCommand, exitedTerminals, diff --git a/src/components/layout/folder-name-dropdown.tsx b/src/components/layout/folder-name-dropdown.tsx deleted file mode 100644 index 8e78d78..0000000 --- a/src/components/layout/folder-name-dropdown.tsx +++ /dev/null @@ -1,173 +0,0 @@ -"use client" - -import { useState } from "react" -import { - ChevronDown, - Folder, - FolderOpen, - GitBranch, - Rocket, -} from "lucide-react" -import { useTranslations } from "next-intl" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { - focusFolderWindow, - listOpenFolders, - loadFolderHistory, - openFolderWindow, - openProjectBootWindow, -} from "@/lib/api" -import { isDesktop, openFileDialog } from "@/lib/platform" -import { useFolderContext } from "@/contexts/folder-context" -import { CloneDialog } from "@/components/welcome/clone-dialog" -import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog" -import type { FolderHistoryEntry } from "@/lib/types" - -export function FolderNameDropdown() { - const t = useTranslations("Folder.folderNameDropdown") - const { folder } = useFolderContext() - const [openFolders, setOpenFolders] = useState([]) - const [history, setHistory] = useState([]) - const [cloneOpen, setCloneOpen] = useState(false) - const [browserOpen, setBrowserOpen] = useState(false) - - const folderPath = folder?.path ?? "" - const folderName = folder?.name ?? t("fallbackFolderName") - - async function handleOpenChange(open: boolean) { - if (open) { - try { - const [openEntries, historyEntries] = await Promise.all([ - listOpenFolders(), - loadFolderHistory(), - ]) - setOpenFolders(openEntries) - const openPaths = new Set(openEntries.map((e) => e.path)) - setHistory(historyEntries.filter((e) => !openPaths.has(e.path))) - } catch { - setOpenFolders([]) - setHistory([]) - } - } - } - - async function handleOpenFolder() { - if (isDesktop()) { - const selected = await openFileDialog({ - directory: true, - multiple: false, - }) - if (selected) { - await openFolderWindow( - Array.isArray(selected) ? selected[0] : selected, - { newWindow: true } - ) - } - } else { - setBrowserOpen(true) - } - } - - async function handleSelect(path: string) { - try { - await openFolderWindow(path, { newWindow: true }) - } catch { - // ignore - } - } - - return ( - <> - - - - - - - - {t("openFolder")} - - setCloneOpen(true)}> - - {t("cloneRepository")} - - openProjectBootWindow()}> - - {t("projectBoot")} - - {openFolders.length > 0 && ( - <> - - {t("opened")} - {openFolders.map((entry) => ( - focusFolderWindow(entry.id)} - > - {entry.path === folderPath ? ( - - ) : ( - - )} -
-
- {entry.name} -
-
- {entry.path} -
-
-
- ))} - - )} - {history.length > 0 && ( - <> - - {t("recentOpen")} - {history.map((entry) => ( - handleSelect(entry.path)} - > - -
-
{entry.name}
-
- {entry.path} -
-
-
- ))} - - )} -
-
- - { - openFolderWindow(path, { newWindow: true }).catch((err) => { - console.error("[FolderNameDropdown] failed to open folder:", err) - }) - }} - /> - - ) -} diff --git a/src/components/layout/folder-title-bar.tsx b/src/components/layout/folder-title-bar.tsx index 4d37206..8b36cb9 100644 --- a/src/components/layout/folder-title-bar.tsx +++ b/src/components/layout/folder-title-bar.tsx @@ -20,9 +20,10 @@ import { SquareTerminal, } from "lucide-react" import { useTranslations } from "next-intl" -import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/api" +import { openSettingsWindow } from "@/lib/api" +import { useAppWorkspace } from "@/contexts/app-workspace-context" +import { useActiveFolder } from "@/contexts/active-folder-context" import { isDesktop, openFileDialog } from "@/lib/platform" -import { useFolderContext } from "@/contexts/folder-context" import { Button } from "@/components/ui/button" import { useSidebarContext } from "@/contexts/sidebar-context" import { useAuxPanelContext } from "@/contexts/aux-panel-context" @@ -36,8 +37,6 @@ import { matchShortcutEvent, } from "@/lib/keyboard-shortcuts" import { AppTitleBar } from "./app-title-bar" -import { FolderNameDropdown } from "./folder-name-dropdown" -import { BranchDropdown } from "./branch-dropdown" import { CommandDropdown } from "./command-dropdown" import { SearchCommandDialog } from "@/components/conversations/search-command-dialog" import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog" @@ -71,7 +70,8 @@ const MODE_TABS = [ export function FolderTitleBar() { const tModes = useTranslations("Folder.modes") const tTitleBar = useTranslations("Folder.folderTitleBar") - const { folder } = useFolderContext() + const { openFolder } = useAppWorkspace() + const { activeFolder } = useActiveFolder() const { isOpen, toggle } = useSidebarContext() const { isOpen: auxPanelOpen, toggle: toggleAuxPanel } = useAuxPanelContext() const { isOpen: terminalOpen, toggle: toggleTerminal } = useTerminalContext() @@ -79,14 +79,8 @@ export function FolderTitleBar() { const { mode, setMode } = useWorkspaceContext() const isMac = useIsMac() const { shortcuts } = useShortcutSettings() - const [branch, setBranch] = useState(null) const [searchOpen, setSearchOpen] = useState(false) const [browserOpen, setBrowserOpen] = useState(false) - const pollTimerRef = useRef | undefined>( - undefined - ) - - const folderPath = folder?.path ?? "" const handleOpenFolder = useCallback(async () => { if (isDesktop()) { @@ -97,14 +91,14 @@ export function FolderTitleBar() { }) if (!result) return const selected = Array.isArray(result) ? result[0] : result - await openFolderWindow(selected, { newWindow: true }) + await openFolder(selected) } catch (err) { console.error("[FolderTitleBar] failed to open folder:", err) } } else { setBrowserOpen(true) } - }, []) + }, [openFolder]) const handleOpenSettings = useCallback(() => { openSettingsWindow().catch((err) => { @@ -112,63 +106,6 @@ export function FolderTitleBar() { }) }, []) - useEffect(() => { - if (!folderPath) return - let cancelled = false - - // 10s when we have a branch, 60s when we don't. The slow poll still - // discovers a branch created externally (e.g. `git init` in a terminal) - // without hammering the backend when there is nothing to find. - const POLL_FAST_MS = 10_000 - const POLL_SLOW_MS = 60_000 - - const clearPoll = () => { - if (pollTimerRef.current !== undefined) { - clearTimeout(pollTimerRef.current) - pollTimerRef.current = undefined - } - } - - const scheduleNext = (delayMs: number) => { - clearPoll() - pollTimerRef.current = setTimeout(() => { - pollTimerRef.current = undefined - void doFetch() - }, delayMs) - } - - async function doFetch() { - if (document.visibilityState !== "visible") return - - let nextDelayMs = POLL_FAST_MS - try { - const b = await getGitBranch(folderPath) - if (cancelled) return - setBranch(b) - if (b === null) nextDelayMs = POLL_SLOW_MS - } catch { - if (!cancelled) setBranch(null) - nextDelayMs = POLL_SLOW_MS - } - if (!cancelled) scheduleNext(nextDelayMs) - } - - function handleVisibilityChange() { - if (document.visibilityState === "visible") { - void doFetch() - } - } - - void doFetch() - document.addEventListener("visibilitychange", handleVisibilityChange) - - return () => { - cancelled = true - clearPoll() - document.removeEventListener("visibilitychange", handleVisibilityChange) - } - }, [folderPath]) - useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if (matchShortcutEvent(e, shortcuts.toggle_search)) { @@ -192,9 +129,9 @@ export function FolderTitleBar() { return } if (matchShortcutEvent(e, shortcuts.new_conversation)) { - if (!folderPath) return + if (!activeFolder) return e.preventDefault() - openNewConversationTab(folderPath) + openNewConversationTab(activeFolder.id, activeFolder.path) return } if (matchShortcutEvent(e, shortcuts.open_folder)) { @@ -210,7 +147,7 @@ export function FolderTitleBar() { document.addEventListener("keydown", handleKeyDown) return () => document.removeEventListener("keydown", handleKeyDown) }, [ - folderPath, + activeFolder, handleOpenFolder, handleOpenSettings, openNewConversationTab, @@ -220,14 +157,6 @@ export function FolderTitleBar() { toggleTerminal, ]) - const refreshBranch = useCallback(async () => { - if (!folderPath) return - try { - setBranch(await getGitBranch(folderPath)) - } catch { - setBranch(null) - } - }, [folderPath]) const isMobile = useIsMobile() const modeContainerRef = useRef(null) const modeItemRefs = useRef>(new Map()) @@ -326,7 +255,6 @@ export function FolderTitleBar() { className="block h-3 w-3 shrink-0" shapeRendering="geometricPrecision" /> - {/* Hide text labels on mobile to save space */} {!isMobile && ( - -
) : ( -
- - -
-
+
) } center={isMobile ? undefined : modeTabsElement} @@ -511,7 +425,7 @@ export function FolderTitleBar() { open={browserOpen} onOpenChange={setBrowserOpen} onSelect={(path) => { - openFolderWindow(path, { newWindow: true }).catch((err) => { + openFolder(path).catch((err) => { console.error("[FolderTitleBar] failed to open folder:", err) }) }} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 80b8ea1..ad60239 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -1,9 +1,21 @@ "use client" -import { useCallback, useRef } from "react" -import { ChevronsDownUp, ChevronsUpDown, Crosshair, Plus } from "lucide-react" +import { useCallback, useEffect, useRef, useState } from "react" +import { + ChevronsDownUp, + ChevronsUpDown, + Crosshair, + FolderPlus, + FolderTree, + Plus, + Rows3, + Search, + X, +} from "lucide-react" import { useTranslations } from "next-intl" -import { useFolderContext } from "@/contexts/folder-context" +import { toast } from "sonner" +import { useActiveFolder } from "@/contexts/active-folder-context" +import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useTabContext } from "@/contexts/tab-context" import { useSidebarContext } from "@/contexts/sidebar-context" import { @@ -11,66 +23,213 @@ import { type SidebarConversationListHandle, } from "@/components/conversations/sidebar-conversation-list" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" import { useIsMobile } from "@/hooks/use-mobile" +import { isDesktop, openFileDialog } from "@/lib/platform" +import { + loadSidebarViewMode, + saveSidebarViewMode, + type SidebarViewMode, +} from "@/lib/sidebar-view-mode-storage" +import { cn } from "@/lib/utils" export function Sidebar() { const t = useTranslations("Folder.sidebar") - const { folder } = useFolderContext() + const { activeFolder } = useActiveFolder() + const { allFolders, conversations, openFolder } = useAppWorkspace() const { openNewConversationTab } = useTabContext() const { isOpen, toggle } = useSidebarContext() const isMobile = useIsMobile() const listRef = useRef(null) + const [viewMode, setViewMode] = useState("flat") + const [searchQuery, setSearchQuery] = useState("") + + useEffect(() => { + // Hydrate from localStorage after mount to keep SSR/CSR markup consistent. + // eslint-disable-next-line react-hooks/set-state-in-effect + setViewMode(loadSidebarViewMode()) + }, []) + + const handleSetViewMode = useCallback((mode: SidebarViewMode) => { + setViewMode(mode) + saveSidebarViewMode(mode) + }, []) + + useEffect(() => { + const onReveal = (e: Event) => { + const detail = (e as CustomEvent<{ folderId: number }>).detail + if (!detail) return + if (viewMode !== "grouped") { + setViewMode("grouped") + saveSidebarViewMode("grouped") + } + listRef.current?.revealFolder(detail.folderId) + } + window.addEventListener("sidebar:reveal-folder", onReveal) + return () => { + window.removeEventListener("sidebar:reveal-folder", onReveal) + } + }, [viewMode]) + const handleNewConversation = useCallback(() => { - if (!folder) return - openNewConversationTab(folder.path) - }, [folder, openNewConversationTab]) + if (!activeFolder) return + openNewConversationTab(activeFolder.id, activeFolder.path) + }, [activeFolder, openNewConversationTab]) + + const handleOpenFolder = useCallback(async () => { + try { + if (!isDesktop()) { + toast.error(t("toasts.openFolderFailed")) + return + } + const result = await openFileDialog({ + directory: true, + multiple: false, + }) + if (!result) return + const selected = Array.isArray(result) ? result[0] : result + const detail = await openFolder(selected) + toast.success(t("toasts.folderOpened", { name: detail.name })) + } catch (err) { + console.error("[Sidebar] open folder failed:", err) + toast.error(t("toasts.openFolderFailed")) + } + }, [openFolder, t]) if (!isOpen) return null return ( ) diff --git a/src/components/layout/status-bar-connection.tsx b/src/components/layout/status-bar-connection.tsx index eeb0108..5671b30 100644 --- a/src/components/layout/status-bar-connection.tsx +++ b/src/components/layout/status-bar-connection.tsx @@ -5,7 +5,7 @@ import { Unplug } from "lucide-react" import { useTranslations } from "next-intl" import { useConnectionStore } from "@/contexts/acp-connections-context" import { useTabContext } from "@/contexts/tab-context" -import { useFolderContext } from "@/contexts/folder-context" +import { useAppWorkspace } from "@/contexts/app-workspace-context" import { AgentIcon } from "@/components/agent-icon" import { Tooltip, @@ -42,7 +42,7 @@ export function StatusBarConnection() { const t = useTranslations("Folder.statusBar.connection") const store = useConnectionStore() const { tabs, activeTabId } = useTabContext() - const { conversations } = useFolderContext() + const { conversations } = useAppWorkspace() // Subscribe to activeKey changes const subscribeActiveKey = useCallback( diff --git a/src/components/layout/status-bar-session-info.tsx b/src/components/layout/status-bar-session-info.tsx index 61c720d..52672f2 100644 --- a/src/components/layout/status-bar-session-info.tsx +++ b/src/components/layout/status-bar-session-info.tsx @@ -3,11 +3,11 @@ import { useMemo } from "react" import { GitBranch } from "lucide-react" import { useTabContext } from "@/contexts/tab-context" -import { useFolderContext } from "@/contexts/folder-context" +import { useAppWorkspace } from "@/contexts/app-workspace-context" export function StatusBarSessionInfo() { const { tabs, activeTabId } = useTabContext() - const { conversations } = useFolderContext() + const { conversations } = useAppWorkspace() const activeTab = useMemo( () => tabs.find((t) => t.id === activeTabId) ?? null, diff --git a/src/components/layout/status-bar-stats.tsx b/src/components/layout/status-bar-stats.tsx index 8c58dc0..6857039 100644 --- a/src/components/layout/status-bar-stats.tsx +++ b/src/components/layout/status-bar-stats.tsx @@ -3,7 +3,7 @@ import { useMemo } from "react" import { BarChart3 } from "lucide-react" import { useTranslations } from "next-intl" -import { useFolderContext } from "@/contexts/folder-context" +import { useAppWorkspace } from "@/contexts/app-workspace-context" import { AGENT_LABELS } from "@/lib/types" import { AgentIcon } from "@/components/agent-icon" import { @@ -14,7 +14,7 @@ import { export function StatusBarStats() { const t = useTranslations("Folder.statusBar.stats") - const { stats } = useFolderContext() + const { stats } = useAppWorkspace() const activeAgents = useMemo( () => stats?.by_agent.filter((a) => a.conversation_count > 0) ?? [], diff --git a/src/components/project-boot/shadcn/create-project-dialog.tsx b/src/components/project-boot/shadcn/create-project-dialog.tsx index c71242f..e1a4033 100644 --- a/src/components/project-boot/shadcn/create-project-dialog.tsx +++ b/src/components/project-boot/shadcn/create-project-dialog.tsx @@ -37,7 +37,7 @@ import { import { isDesktop, openFileDialog } from "@/lib/platform" import { createShadcnProject, - openFolderWindow, + openFolder, detectPackageManager, } from "@/lib/api" import { extractAppCommandError, toErrorMessage } from "@/lib/app-error" @@ -122,7 +122,7 @@ export function CreateProjectDialog({ toast.success(t("toasts.createSuccess")) onOpenChange(false) resetForm() - await openFolderWindow(projectPath) + await openFolder(projectPath) } catch (err) { const appErr = extractAppCommandError(err) const message = diff --git a/src/components/settings/experts-settings.tsx b/src/components/settings/experts-settings.tsx index e7652dc..01b75ca 100644 --- a/src/components/settings/experts-settings.tsx +++ b/src/components/settings/experts-settings.tsx @@ -44,7 +44,7 @@ import { expertsOpenCentralDir, expertsReadContent, expertsUnlinkFromAgent, - openFolderWindow, + openFolder, } from "@/lib/api" import { invalidateAgentExpertsCache } from "@/hooks/use-agent-experts" import type { @@ -369,7 +369,7 @@ export function ExpertsSettings() { const handleOpenCentralDir = useCallback(async () => { try { const path = await expertsOpenCentralDir() - await openFolderWindow(path) + await openFolder(path) } catch (err) { const message = err instanceof Error ? err.message : String(err) toast.error(t("toasts.openFolderFailed"), { description: message }) diff --git a/src/components/settings/skills-settings.tsx b/src/components/settings/skills-settings.tsx index 9549655..75b6ccc 100644 --- a/src/components/settings/skills-settings.tsx +++ b/src/components/settings/skills-settings.tsx @@ -53,7 +53,7 @@ import { acpListAgents, acpListAgentSkills, loadFolderHistory, - openFolderWindow, + openFolder, acpReadAgentSkill, acpSaveAgentSkill, } from "@/lib/api" @@ -475,7 +475,7 @@ export function SkillsSettings() { async (skill: AgentSkillItem) => { const dirPath = skillDirectoryPath(skill) try { - await openFolderWindow(dirPath) + await openFolder(dirPath) } catch (err) { const message = err instanceof Error ? err.message : String(err) toast.error(t("toasts.openFolderFailed"), { description: message }) diff --git a/src/components/tabs/tab-bar.tsx b/src/components/tabs/tab-bar.tsx index 41b0f27..4a1f429 100644 --- a/src/components/tabs/tab-bar.tsx +++ b/src/components/tabs/tab-bar.tsx @@ -1,7 +1,8 @@ "use client" -import { useCallback, useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Reorder } from "motion/react" +import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useTabContext } from "@/contexts/tab-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" @@ -18,11 +19,25 @@ export function TabBar() { closeTab, closeOtherTabs, closeAllTabs, + closeTabsByFolder, pinTab, toggleTileMode, reorderTabs, } = useTabContext() + const { folders, branches } = useAppWorkspace() const { mode, activePane } = useWorkspaceContext() + + const folderIndex = useMemo(() => { + const map = new Map() + for (const f of folders) map.set(f.id, { name: f.name }) + return map + }, [folders]) + + const handleRevealInSidebar = useCallback((folderId: number) => { + window.dispatchEvent( + new CustomEvent("sidebar:reveal-folder", { detail: { folderId } }) + ) + }, []) const { shortcuts } = useShortcutSettings() const scrollRef = useRef(null) const [isHovered, setIsHovered] = useState(false) @@ -86,20 +101,27 @@ export function TabBar() { : ["pb-1.5", "[&::-webkit-scrollbar]:h-0"] )} > - {tabs.map((tab) => ( - - ))} + {tabs.map((tab) => { + const folderInfo = folderIndex.get(tab.folderId) + return ( + + ) + })} ) } diff --git a/src/components/tabs/tab-item.tsx b/src/components/tabs/tab-item.tsx index fabdb25..6f660f0 100644 --- a/src/components/tabs/tab-item.tsx +++ b/src/components/tabs/tab-item.tsx @@ -20,10 +20,14 @@ interface TabItemProps { tab: TabItemData isActive: boolean isTileMode: boolean + folderName: string | null + folderBranch: string | null onSwitch: (tabId: string) => void onClose: (tabId: string) => void onCloseOthers: (tabId: string) => void onCloseAll: () => void + onCloseFolderTabs: (folderId: number) => void + onRevealInSidebar: (folderId: number) => void onPin: (tabId: string) => void onToggleTile: () => void } @@ -32,10 +36,14 @@ export const TabItem = memo(function TabItem({ tab, isActive, isTileMode, + folderName, + folderBranch, onSwitch, onClose, onCloseOthers, onCloseAll, + onCloseFolderTabs, + onRevealInSidebar, onPin, onToggleTile, }: TabItemProps) { @@ -43,6 +51,19 @@ export const TabItem = memo(function TabItem({ const isDragging = useRef(false) const itemRef = useRef(null) + const resolvedFolderName = folderName ?? String(tab.folderId) + const tooltip = folderBranch + ? `${resolvedFolderName} · ${folderBranch} — ${tab.title}` + : `${resolvedFolderName} — ${tab.title}` + + const handleCloseFolderTabs = useCallback(() => { + onCloseFolderTabs(tab.folderId) + }, [onCloseFolderTabs, tab.folderId]) + + const handleRevealInSidebar = useCallback(() => { + onRevealInSidebar(tab.folderId) + }, [onRevealInSidebar, tab.folderId]) + const clearResidualStyles = useCallback(() => { const el = itemRef.current if (!el) return @@ -119,7 +140,7 @@ export const TabItem = memo(function TabItem({ "truncate max-w-[140px]", !tab.isPinned && "[font-style:oblique]" )} - title={tab.title} + title={tooltip} > {tab.title} @@ -146,7 +167,13 @@ export const TabItem = memo(function TabItem({ {t("closeOthers")} + + {t("closeFolderTabs", { folder: resolvedFolderName })} + + + {t("revealInSidebar")} + {isTileMode ? t("untileDisplay") : t("tileDisplay")} diff --git a/src/components/terminal/terminal-tab-bar.tsx b/src/components/terminal/terminal-tab-bar.tsx index 8d5c509..a93904d 100644 --- a/src/components/terminal/terminal-tab-bar.tsx +++ b/src/components/terminal/terminal-tab-bar.tsx @@ -1,8 +1,10 @@ "use client" -import { useRef, useState } from "react" +import { useMemo, useRef, useState } from "react" import { Minus, Plus, X } from "lucide-react" import { useTranslations } from "next-intl" +import { useActiveFolder } from "@/contexts/active-folder-context" +import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useTerminalContext } from "@/contexts/terminal-context" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { useIsMac } from "@/hooks/use-is-mac" @@ -15,6 +17,13 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu" +import { FolderBadge } from "@/components/ui/folder-badge" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" export function TerminalTabBar() { const t = useTranslations("Folder.terminal") @@ -31,6 +40,16 @@ export function TerminalTabBar() { createTerminal, toggle, } = useTerminalContext() + const { activeFolderId } = useActiveFolder() + const { folders } = useAppWorkspace() + + const folderIndex = useMemo(() => { + const map = new Map() + for (const f of folders) map.set(f.id, f.name) + return map + }, [folders]) + + const canCreateTerminal = activeFolderId != null const [editingId, setEditingId] = useState(null) const [editValue, setEditValue] = useState("") @@ -62,6 +81,13 @@ export function TerminalTabBar() { }`} onClick={() => switchTerminal(tab.id)} > + {editingId === tab.id ? ( ))} - + + + + + + + + {!canCreateTerminal && ( + {t("openFolderFirst")} + )} + + - } - /> - -
-
- - -
- -
- -
- ) -} diff --git a/src/components/welcome/clone-dialog.tsx b/src/components/workspace-empty/clone-dialog.tsx similarity index 95% rename from src/components/welcome/clone-dialog.tsx rename to src/components/workspace-empty/clone-dialog.tsx index 2fcd891..370f55d 100644 --- a/src/components/welcome/clone-dialog.tsx +++ b/src/components/workspace-empty/clone-dialog.tsx @@ -3,8 +3,9 @@ import { useState, useMemo } from "react" import { useTranslations } from "next-intl" import { toast } from "sonner" -import { cloneRepository, openFolderWindow } from "@/lib/api" +import { cloneRepository } from "@/lib/api" import { isDesktop, openFileDialog } from "@/lib/platform" +import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useGitCredential } from "@/contexts/git-credential-context" import { Dialog, @@ -17,7 +18,7 @@ import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { Label } from "@/components/ui/label" import { FolderOpen, Loader2 } from "lucide-react" -import { resolveCloneError } from "@/components/welcome/error-utils" +import { resolveCloneError } from "@/components/workspace-empty/error-utils" import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog" interface CloneDialogProps { @@ -28,6 +29,7 @@ interface CloneDialogProps { export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) { const t = useTranslations("WelcomePage") const { withCredentialRetry } = useGitCredential() + const { openFolder } = useAppWorkspace() const [url, setUrl] = useState("") const [targetDir, setTargetDir] = useState("") const [cloning, setCloning] = useState(false) @@ -73,7 +75,7 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) { (creds) => cloneRepository(url, fullPath, creds), { remoteUrl: url } ) - await openFolderWindow(fullPath) + await openFolder(fullPath) onOpenChange(false) resetForm() } catch (err) { diff --git a/src/components/welcome/error-utils.ts b/src/components/workspace-empty/error-utils.ts similarity index 100% rename from src/components/welcome/error-utils.ts rename to src/components/workspace-empty/error-utils.ts diff --git a/src/components/welcome/folder-actions.tsx b/src/components/workspace-empty/folder-actions.tsx similarity index 90% rename from src/components/welcome/folder-actions.tsx rename to src/components/workspace-empty/folder-actions.tsx index 95d4434..8d1972e 100644 --- a/src/components/welcome/folder-actions.tsx +++ b/src/components/workspace-empty/folder-actions.tsx @@ -4,15 +4,17 @@ import { useState } from "react" import { FolderOpen, GitBranch, Rocket } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" -import { openFolderWindow, openProjectBootWindow } from "@/lib/api" +import { useAppWorkspace } from "@/contexts/app-workspace-context" +import { openProjectBootWindow } from "@/lib/api" import { isDesktop, openFileDialog } from "@/lib/platform" import { Button } from "@/components/ui/button" import { CloneDialog } from "./clone-dialog" -import { resolveWelcomeError } from "@/components/welcome/error-utils" +import { resolveWelcomeError } from "@/components/workspace-empty/error-utils" import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog" export function FolderActions() { const t = useTranslations("WelcomePage") + const { openFolder } = useAppWorkspace() const [cloneOpen, setCloneOpen] = useState(false) const [browserOpen, setBrowserOpen] = useState(false) @@ -25,7 +27,7 @@ export function FolderActions() { if (!result) return const selected = Array.isArray(result) ? result[0] : result try { - await openFolderWindow(selected) + await openFolder(selected) } catch (err) { console.error("[FolderActions] failed to open folder:", err) const resolvedError = resolveWelcomeError(err) @@ -40,7 +42,7 @@ export function FolderActions() { const handleBrowserSelect = async (path: string) => { try { - await openFolderWindow(path) + await openFolder(path) } catch (err) { console.error("[FolderActions] failed to open folder:", err) const resolvedError = resolveWelcomeError(err) diff --git a/src/components/welcome/folder-list.tsx b/src/components/workspace-empty/folder-list.tsx similarity index 94% rename from src/components/welcome/folder-list.tsx rename to src/components/workspace-empty/folder-list.tsx index 974746b..0d1c92e 100644 --- a/src/components/welcome/folder-list.tsx +++ b/src/components/workspace-empty/folder-list.tsx @@ -6,10 +6,11 @@ import { formatDistanceToNow } from "date-fns" import { enUS, zhCN, zhTW } from "date-fns/locale" import { useLocale, useTranslations } from "next-intl" import { toast } from "sonner" -import { openFolderWindow, removeFolderFromHistory } from "@/lib/api" +import { useAppWorkspace } from "@/contexts/app-workspace-context" +import { removeFolderFromHistory } from "@/lib/api" import type { FolderHistoryEntry } from "@/lib/types" import { Input } from "@/components/ui/input" -import { resolveWelcomeError } from "@/components/welcome/error-utils" +import { resolveWelcomeError } from "@/components/workspace-empty/error-utils" interface FolderListProps { history: FolderHistoryEntry[] @@ -20,6 +21,7 @@ interface FolderListProps { export function FolderList({ history, loading, onRefresh }: FolderListProps) { const t = useTranslations("WelcomePage") const locale = useLocale() + const { openFolder } = useAppWorkspace() const [search, setSearch] = useState("") const dateFnsLocale = locale === "zh-CN" ? zhCN : locale === "zh-TW" ? zhTW : enUS @@ -37,7 +39,7 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) { const handleOpen = async (path: string) => { try { - await openFolderWindow(path) + await openFolder(path) } catch (err) { console.error("Failed to open folder:", err) const resolvedError = resolveWelcomeError(err) diff --git a/src/components/welcome/software-info.tsx b/src/components/workspace-empty/software-info.tsx similarity index 100% rename from src/components/welcome/software-info.tsx rename to src/components/workspace-empty/software-info.tsx diff --git a/src/components/workspace-empty/workspace-empty.tsx b/src/components/workspace-empty/workspace-empty.tsx new file mode 100644 index 0000000..3cf6540 --- /dev/null +++ b/src/components/workspace-empty/workspace-empty.tsx @@ -0,0 +1,41 @@ +"use client" + +import { useEffect, useState, useCallback } from "react" +import { loadFolderHistory } from "@/lib/api" +import type { FolderHistoryEntry } from "@/lib/types" +import { FolderActions } from "./folder-actions" +import { FolderList } from "./folder-list" +import { SoftwareInfo } from "./software-info" + +export function WorkspaceEmpty() { + const [history, setHistory] = useState([]) + const [loading, setLoading] = useState(true) + + const refresh = useCallback(async () => { + setLoading(true) + try { + const result = await loadFolderHistory() + setHistory(result) + } catch (err) { + console.error("[WorkspaceEmpty] loadFolderHistory failed:", err) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void refresh() + }, [refresh]) + + return ( +
+ +
+ +
+
+ ) +} diff --git a/src/components/workspace/deep-link-bootstrap.tsx b/src/components/workspace/deep-link-bootstrap.tsx new file mode 100644 index 0000000..d23a15f --- /dev/null +++ b/src/components/workspace/deep-link-bootstrap.tsx @@ -0,0 +1,89 @@ +"use client" + +import { useEffect, useRef } from "react" +import { toast } from "sonner" +import { useAppWorkspace } from "@/contexts/app-workspace-context" +import { useTabContext } from "@/contexts/tab-context" +import type { AgentType } from "@/lib/types" + +/** + * Handles `/workspace?folderId=X&conversationId=Y&agent=Z` URLs. + * Runs once after both folders and tabs have hydrated. + */ +export function DeepLinkBootstrap() { + const { foldersHydrated, folders, addFolderToWorkspaceById, conversations } = + useAppWorkspace() + const { tabsHydrated, openTab } = useTabContext() + const ranRef = useRef(false) + + useEffect(() => { + if (ranRef.current) return + if (!foldersHydrated || !tabsHydrated) return + ranRef.current = true + + if (typeof window === "undefined") return + + const params = new URLSearchParams(window.location.search) + const rawFolderId = params.get("folderId") + const rawConversationId = params.get("conversationId") + const rawAgent = params.get("agent") as AgentType | null + + if (!rawFolderId && !rawConversationId) return + + const clearUrl = () => { + try { + window.history.replaceState({}, "", "/workspace") + } catch { + /* ignore */ + } + } + + void (async () => { + try { + const folderId = rawFolderId ? Number(rawFolderId) : null + const conversationId = rawConversationId + ? Number(rawConversationId) + : null + + if (folderId == null || !Number.isFinite(folderId)) return + if (conversationId == null || !Number.isFinite(conversationId)) return + if (!rawAgent) return + + let folder = folders.find((f) => f.id === folderId) + if (!folder) { + try { + folder = await addFolderToWorkspaceById(folderId) + } catch (err) { + console.error("[DeepLinkBootstrap] open folder failed:", err) + toast.error("Unable to open linked folder") + return + } + } + + const hasConv = conversations.some( + (c) => + c.id === conversationId && + c.folder_id === folderId && + c.agent_type === rawAgent + ) + if (!hasConv) { + toast.error("Linked conversation not found") + return + } + + openTab(folderId, conversationId, rawAgent, true) + } finally { + clearUrl() + } + })() + }, [ + foldersHydrated, + tabsHydrated, + folders, + conversations, + addFolderToWorkspaceById, + openTab, + ]) + + return null +} diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 4f73e45..59fc222 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -51,7 +51,7 @@ import { clearStalePrefs, } from "@/lib/selector-prefs-storage" import { useAlertContext, type AlertAction } from "@/contexts/alert-context" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" // ── Shared types (re-exported for consumers) ── @@ -1330,7 +1330,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { const t = useTranslations("Folder.chat.acpConnections") const tChat = useTranslations("Folder.chat") const { pushAlert } = useAlertContext() - const { folder } = useFolderContext() + const { activeFolder: folder } = useActiveFolder() const folderNameRef = useRef(folder?.name) useEffect(() => { folderNameRef.current = folder?.name diff --git a/src/contexts/active-folder-context.tsx b/src/contexts/active-folder-context.tsx new file mode 100644 index 0000000..11a8103 --- /dev/null +++ b/src/contexts/active-folder-context.tsx @@ -0,0 +1,43 @@ +"use client" + +import { createContext, useContext, useMemo, type ReactNode } from "react" +import { useAppWorkspace } from "@/contexts/app-workspace-context" +import type { FolderDetail } from "@/lib/types" + +interface ActiveFolderContextValue { + activeFolderId: number | null + activeFolder: FolderDetail | null +} + +const ActiveFolderContext = createContext(null) + +export function useActiveFolder() { + const ctx = useContext(ActiveFolderContext) + if (!ctx) { + throw new Error("useActiveFolder must be used within ActiveFolderProvider") + } + return ctx +} + +export function ActiveFolderProvider({ children }: { children: ReactNode }) { + const { folders, activeFolderId } = useAppWorkspace() + + const activeFolder = useMemo( + () => + activeFolderId != null + ? (folders.find((f) => f.id === activeFolderId) ?? null) + : null, + [activeFolderId, folders] + ) + + const value = useMemo( + () => ({ activeFolderId, activeFolder }), + [activeFolderId, activeFolder] + ) + + return ( + + {children} + + ) +} diff --git a/src/contexts/app-workspace-context.tsx b/src/contexts/app-workspace-context.tsx new file mode 100644 index 0000000..8e089ff --- /dev/null +++ b/src/contexts/app-workspace-context.tsx @@ -0,0 +1,383 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react" +import { + getGitBranch, + listAllConversations, + listAllFolderDetails, + listOpenFolderDetails, + openFolder as apiOpenFolder, + openFolderById as apiOpenFolderById, + removeFolderFromWorkspace as apiRemoveFolderFromWorkspace, + getFolder as apiGetFolder, +} from "@/lib/api" +import { toErrorMessage } from "@/lib/app-error" +import type { + AgentStats, + AgentType, + DbConversationSummary, + FolderDetail, +} from "@/lib/types" + +interface AppWorkspaceContextValue { + folders: FolderDetail[] + allFolders: FolderDetail[] + foldersHydrated: boolean + foldersLoading: boolean + getFolder: (id: number) => FolderDetail | undefined + + conversations: DbConversationSummary[] + conversationsLoading: boolean + conversationsError: string | null + refreshConversations: () => void + updateConversationLocal: ( + id: number, + patch: Partial> + ) => void + + branches: Map + getBranch: (folderId: number) => string | null | undefined + setBranch: (folderId: number, branch: string | null) => void + + openFolder: (path: string) => Promise + addFolderToWorkspaceById: (folderId: number) => Promise + removeFolderFromWorkspace: (folderId: number) => Promise + refreshFolder: (id: number) => Promise + + stats: AgentStats | null + + /** + * Currently-active folder id as driven by the active tab. + * TabProvider sets this; ActiveFolderProvider / other consumers read it. + */ + activeFolderId: number | null + setActiveFolderId: (id: number | null) => void +} + +const AppWorkspaceContext = createContext(null) + +export function useAppWorkspace() { + const ctx = useContext(AppWorkspaceContext) + if (!ctx) { + throw new Error("useAppWorkspace must be used within AppWorkspaceProvider") + } + return ctx +} + +function computeStats(conversations: DbConversationSummary[]): AgentStats { + const byAgent = new Map() + let totalMessages = 0 + + for (const s of conversations) { + byAgent.set(s.agent_type, (byAgent.get(s.agent_type) ?? 0) + 1) + totalMessages += s.message_count + } + + return { + total_conversations: conversations.length, + total_messages: totalMessages, + by_agent: Array.from(byAgent.entries()).map(([agent_type, count]) => ({ + agent_type, + conversation_count: count, + })), + } +} + +interface AppWorkspaceProviderProps { + children: ReactNode +} + +export function AppWorkspaceProvider({ children }: AppWorkspaceProviderProps) { + const [folders, setFolders] = useState([]) + const [allFolders, setAllFolders] = useState([]) + const [foldersHydrated, setFoldersHydrated] = useState(false) + const [foldersLoading, setFoldersLoading] = useState(true) + + const [conversations, setConversations] = useState( + [] + ) + const [conversationsLoading, setConversationsLoading] = useState(true) + const [conversationsError, setConversationsError] = useState( + null + ) + + const [branches, setBranches] = useState>( + new Map() + ) + const [activeFolderId, setActiveFolderId] = useState(null) + + const mountedRef = useRef(true) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const fetchFolders = useCallback(async () => { + setFoldersLoading(true) + try { + const [openList, allList] = await Promise.all([ + listOpenFolderDetails(), + listAllFolderDetails(), + ]) + if (!mountedRef.current) return + setFolders(openList) + setAllFolders(allList) + setBranches((prev) => { + const next = new Map(prev) + for (const f of openList) { + if (!next.has(f.id)) { + next.set(f.id, f.git_branch ?? null) + } + } + return next + }) + } catch (err) { + console.error("[AppWorkspace] fetchFolders failed:", err) + } finally { + if (mountedRef.current) { + setFoldersLoading(false) + setFoldersHydrated(true) + } + } + }, []) + + const refreshConversations = useCallback(async () => { + setConversationsLoading(true) + try { + const list = await listAllConversations() + if (!mountedRef.current) return + setConversations(list) + setConversationsError(null) + } catch (err) { + if (!mountedRef.current) return + setConversationsError(toErrorMessage(err)) + } finally { + if (mountedRef.current) { + setConversationsLoading(false) + } + } + }, []) + + useEffect(() => { + void fetchFolders() + void refreshConversations() + }, [fetchFolders, refreshConversations]) + + const getFolder = useCallback( + (id: number) => folders.find((f) => f.id === id), + [folders] + ) + + const updateConversationLocal = useCallback( + ( + id: number, + patch: Partial> + ) => { + const now = new Date().toISOString() + setConversations((prev) => + prev.map((c) => (c.id === id ? { ...c, ...patch, updated_at: now } : c)) + ) + }, + [] + ) + + const getBranch = useCallback( + (folderId: number) => branches.get(folderId), + [branches] + ) + + const setBranch = useCallback((folderId: number, branch: string | null) => { + setBranches((prev) => { + const next = new Map(prev) + next.set(folderId, branch) + return next + }) + }, []) + + const upsertFolder = useCallback((detail: FolderDetail) => { + const upsert = (prev: FolderDetail[]) => { + const idx = prev.findIndex((f) => f.id === detail.id) + if (idx >= 0) { + const updated = [...prev] + updated[idx] = detail + return updated + } + return [...prev, detail] + } + setFolders(upsert) + setAllFolders(upsert) + }, []) + + const openFolder = useCallback( + async (path: string) => { + const detail = await apiOpenFolder(path) + upsertFolder(detail) + setBranches((prev) => { + const next = new Map(prev) + next.set(detail.id, detail.git_branch ?? null) + return next + }) + void refreshConversations() + return detail + }, + [refreshConversations, upsertFolder] + ) + + const addFolderToWorkspaceById = useCallback( + async (folderId: number) => { + const detail = await apiOpenFolderById(folderId) + upsertFolder(detail) + setBranches((prev) => { + const next = new Map(prev) + next.set(detail.id, detail.git_branch ?? null) + return next + }) + void refreshConversations() + return detail + }, + [refreshConversations, upsertFolder] + ) + + const removeFolderFromWorkspace = useCallback( + async (folderId: number) => { + await apiRemoveFolderFromWorkspace(folderId) + setFolders((prev) => prev.filter((f) => f.id !== folderId)) + setBranches((prev) => { + if (!prev.has(folderId)) return prev + const next = new Map(prev) + next.delete(folderId) + return next + }) + void refreshConversations() + }, + [refreshConversations] + ) + + const refreshFolder = useCallback(async (id: number) => { + try { + const detail = await apiGetFolder(id) + const patch = (prev: FolderDetail[]) => { + const idx = prev.findIndex((f) => f.id === id) + if (idx < 0) return prev + const updated = [...prev] + updated[idx] = detail + return updated + } + setFolders(patch) + setAllFolders(patch) + setBranches((prev) => { + const next = new Map(prev) + next.set(id, detail.git_branch ?? null) + return next + }) + } catch (err) { + console.error("[AppWorkspace] refreshFolder failed:", err) + } + }, []) + + // Branch polling: only poll the active folder. + useEffect(() => { + if (activeFolderId == null) return + const folderId = activeFolderId + const folder = folders.find((f) => f.id === folderId) + if (!folder) return + + let cancelled = false + let timer: ReturnType | null = null + + const poll = async () => { + try { + const branch = await getGitBranch(folder.path) + if (cancelled) return + setBranches((prev) => { + const existing = prev.get(folderId) + if (existing === branch) return prev + const next = new Map(prev) + next.set(folderId, branch) + return next + }) + const delay = branch ? 10_000 : 60_000 + timer = setTimeout(poll, delay) + } catch { + if (!cancelled) { + timer = setTimeout(poll, 60_000) + } + } + } + + void poll() + + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + }, [activeFolderId, folders]) + + const stats = useMemo( + () => (conversations.length > 0 ? computeStats(conversations) : null), + [conversations] + ) + + const value = useMemo( + () => ({ + folders, + allFolders, + foldersHydrated, + foldersLoading, + getFolder, + conversations, + conversationsLoading, + conversationsError, + refreshConversations, + updateConversationLocal, + branches, + getBranch, + setBranch, + openFolder, + addFolderToWorkspaceById, + removeFolderFromWorkspace, + refreshFolder, + stats, + activeFolderId, + setActiveFolderId, + }), + [ + folders, + allFolders, + foldersHydrated, + foldersLoading, + getFolder, + conversations, + conversationsLoading, + conversationsError, + refreshConversations, + updateConversationLocal, + branches, + getBranch, + setBranch, + openFolder, + addFolderToWorkspaceById, + removeFolderFromWorkspace, + refreshFolder, + stats, + activeFolderId, + ] + ) + + return ( + + {children} + + ) +} diff --git a/src/contexts/aux-panel-context.tsx b/src/contexts/aux-panel-context.tsx index 07f0239..a526860 100644 --- a/src/contexts/aux-panel-context.tsx +++ b/src/contexts/aux-panel-context.tsx @@ -13,9 +13,12 @@ import { loadPersistedPanelState, savePersistedPanelState, } from "@/lib/panel-state-storage" +import { useActiveFolder } from "@/contexts/active-folder-context" export type AuxPanelTab = "file_tree" | "changes" | "git_log" | "session_files" +const STORAGE_KEY = "workspace:right-sidebar" + const DEFAULT_WIDTH = 320 const MIN_WIDTH = 200 const MAX_WIDTH = 500 @@ -53,17 +56,11 @@ function clampWidth(width: number) { interface AuxPanelProviderProps { children: ReactNode - folderId: number } -export function AuxPanelProvider({ - children, - folderId, -}: AuxPanelProviderProps) { - const storageKey = useMemo( - () => `folder:${folderId}:right-sidebar`, - [folderId] - ) +export function AuxPanelProvider({ children }: AuxPanelProviderProps) { + const storageKey = STORAGE_KEY + const { activeFolderId } = useActiveFolder() const [isOpen, setIsOpen] = useState(DEFAULT_IS_OPEN) const [width, setWidthState] = useState(DEFAULT_WIDTH) const [restored, setRestored] = useState(false) @@ -109,6 +106,14 @@ export function AuxPanelProvider({ savePersistedPanelState(storageKey, { isOpen, width }) }, [isOpen, restored, storageKey, width]) + // Reset pending reveal path when the active folder changes; file tree + // state is content-driven by `useWorkspaceContext` and will refetch + // naturally via its folder-path dependency. + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setPendingRevealPath(null) + }, [activeFolderId]) + const value = useMemo( () => ({ isOpen, diff --git a/src/contexts/folder-context.tsx b/src/contexts/folder-context.tsx deleted file mode 100644 index ca0742d..0000000 --- a/src/contexts/folder-context.tsx +++ /dev/null @@ -1,299 +0,0 @@ -"use client" - -import { - createContext, - useContext, - useEffect, - useMemo, - useState, - useCallback, - useRef, - type ReactNode, -} from "react" -import { toErrorMessage } from "@/lib/app-error" -import { getFolder, listFolderConversations } from "@/lib/api" -import { isDesktop } from "@/lib/transport" -import type { - AgentType, - AgentStats, - DbConversationSummary, - FolderDetail, -} from "@/lib/types" - -interface SelectedConversation { - id: number - agentType: AgentType -} - -interface NewConversationState { - workingDir: string -} - -interface FolderContextValue { - folder: FolderDetail | null - folderId: number - folderLoading: boolean - - conversations: DbConversationSummary[] - loading: boolean - refreshing: boolean - error: string | null - - selectedConversation: SelectedConversation | null - selectConversation: (id: number, agentType: AgentType) => void - clearSelection: () => void - - newConversation: NewConversationState | null - startNewConversation: (workingDir: string) => void - cancelNewConversation: () => void - - stats: AgentStats | null - - refreshConversations: () => void - /** Optimistically update a conversation's status in local state + cache. */ - updateConversationLocal: ( - id: number, - patch: Partial> - ) => void -} - -const FolderContext = createContext(null) - -export function useFolderContext() { - const ctx = useContext(FolderContext) - if (!ctx) { - throw new Error("useFolderContext must be used within FolderProvider") - } - return ctx -} - -function computeStats(conversations: DbConversationSummary[]): AgentStats { - const byAgent = new Map() - let totalMessages = 0 - - for (const s of conversations) { - byAgent.set(s.agent_type, (byAgent.get(s.agent_type) ?? 0) + 1) - totalMessages += s.message_count - } - - return { - total_conversations: conversations.length, - total_messages: totalMessages, - by_agent: Array.from(byAgent.entries()).map(([agent_type, count]) => ({ - agent_type, - conversation_count: count, - })), - } -} - -/** Module-level cache — survives component unmounts / page navigations. */ -const cache = new Map() - -interface FolderProviderProps { - children: ReactNode - folderId: number - initialConversationId?: number | null - initialAgentType?: AgentType | null -} - -export function FolderProvider({ - children, - folderId, - initialConversationId, - initialAgentType, -}: FolderProviderProps) { - // Folder info - const [folder, setFolder] = useState(null) - const [folderLoading, setFolderLoading] = useState(true) - - // Conversations - const cacheKey = String(folderId) - const [conversations, setConversations] = useState( - () => cache.get(cacheKey) ?? [] - ) - const [loading, setLoading] = useState(() => !cache.has(cacheKey)) - const [refreshing, setRefreshing] = useState(false) - const [error, setError] = useState(null) - - const [selectedConversation, setSelectedConversation] = - useState(() => { - if (initialConversationId != null && initialAgentType) { - return { id: initialConversationId, agentType: initialAgentType } - } - return null - }) - - // Sync selection when URL params change (e.g. navigation) - useEffect(() => { - if (initialConversationId != null && initialAgentType) { - setSelectedConversation({ - id: initialConversationId, - agentType: initialAgentType, - }) - } - }, [initialConversationId, initialAgentType]) - const [newConversation, setNewConversation] = - useState(null) - - const mountedRef = useRef(true) - - // Fetch folder info - useEffect(() => { - let cancelled = false - setFolderLoading(true) - getFolder(folderId) - .then((f) => { - if (!cancelled) { - setFolder(f) - setFolderLoading(false) - } - }) - .catch((err) => { - console.error("[FolderProvider] getFolder failed:", err) - if (!cancelled) setFolderLoading(false) - }) - return () => { - cancelled = true - } - }, [folderId]) - - const fetchConversations = useCallback(async () => { - const cached = cache.get(cacheKey) - - if (cached) { - setConversations(cached) - setLoading(false) - setRefreshing(true) - } else { - setLoading(true) - } - - try { - setError(null) - const data = await listFolderConversations({ - folder_id: folderId, - status: null, - }) - if (!mountedRef.current) return - cache.set(cacheKey, data) - setConversations(data) - } catch (e) { - if (!mountedRef.current) return - if (!cached) { - setError(toErrorMessage(e)) - } - } finally { - if (mountedRef.current) { - setLoading(false) - setRefreshing(false) - } - } - }, [folderId, cacheKey]) - - useEffect(() => { - fetchConversations() - }, [fetchConversations]) - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } - }, []) - - // Web mode: register this tab's name so that window.open("", "folder-{id}") - // from other pages can find and reuse it instead of opening duplicates. - useEffect(() => { - if (isDesktop() || !folderId) return - window.name = `folder-${folderId}` - }, [folderId]) - - const selectConversation = useCallback((id: number, agentType: AgentType) => { - setSelectedConversation({ id, agentType }) - setNewConversation(null) - }, []) - - const clearSelection = useCallback(() => { - setSelectedConversation(null) - }, []) - - const startNewConversation = useCallback((workingDir: string) => { - setNewConversation({ workingDir }) - setSelectedConversation(null) - }, []) - - const cancelNewConversation = useCallback(() => { - setNewConversation(null) - }, []) - - const refreshConversations = useCallback(() => { - // Keep cache intact so fetchConversations shows existing data (refreshing - // spinner) instead of falling through to the loading/skeleton path. - fetchConversations() - }, [fetchConversations]) - - const updateConversationLocal = useCallback( - ( - id: number, - patch: Partial> - ) => { - const now = new Date().toISOString() - const apply = (list: DbConversationSummary[]) => - list.map((c) => (c.id === id ? { ...c, ...patch, updated_at: now } : c)) - setConversations((prev) => { - const next = apply(prev) - cache.set(cacheKey, next) - return next - }) - }, - [cacheKey] - ) - - const stats = useMemo( - () => (conversations.length > 0 ? computeStats(conversations) : null), - [conversations] - ) - - const value = useMemo( - () => ({ - folder, - folderId, - folderLoading, - conversations, - loading, - refreshing, - error, - selectedConversation, - selectConversation, - clearSelection, - newConversation, - startNewConversation, - cancelNewConversation, - stats, - refreshConversations, - updateConversationLocal, - }), - [ - folder, - folderId, - folderLoading, - conversations, - loading, - refreshing, - error, - selectedConversation, - selectConversation, - clearSelection, - newConversation, - startNewConversation, - cancelNewConversation, - stats, - refreshConversations, - updateConversationLocal, - ] - ) - - return ( - {children} - ) -} diff --git a/src/contexts/sidebar-context.tsx b/src/contexts/sidebar-context.tsx index a75233b..d0aeeb2 100644 --- a/src/contexts/sidebar-context.tsx +++ b/src/contexts/sidebar-context.tsx @@ -14,6 +14,8 @@ import { savePersistedPanelState, } from "@/lib/panel-state-storage" +const STORAGE_KEY = "workspace:left-sidebar" + const DEFAULT_WIDTH = 320 const MIN_WIDTH = 200 const MAX_WIDTH = 600 @@ -45,14 +47,10 @@ function clampWidth(width: number) { interface SidebarProviderProps { children: ReactNode - folderId: number } -export function SidebarProvider({ children, folderId }: SidebarProviderProps) { - const storageKey = useMemo( - () => `folder:${folderId}:left-sidebar`, - [folderId] - ) +export function SidebarProvider({ children }: SidebarProviderProps) { + const storageKey = STORAGE_KEY const [isOpen, setIsOpen] = useState(DEFAULT_IS_OPEN) const [width, setWidthState] = useState(DEFAULT_WIDTH) const [restored, setRestored] = useState(false) diff --git a/src/contexts/tab-context.tsx b/src/contexts/tab-context.tsx index 6903bf8..ef5336f 100644 --- a/src/contexts/tab-context.tsx +++ b/src/contexts/tab-context.tsx @@ -11,19 +11,16 @@ import { type ReactNode, } from "react" import { useTranslations } from "next-intl" -import { useFolderContext } from "@/contexts/folder-context" +import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useWorkspaceContext } from "@/contexts/workspace-context" -import { saveFolderOpenedConversations } from "@/lib/api" -import type { - AgentType, - ConversationStatus, - OpenedConversation, -} from "@/lib/types" +import { listOpenedTabs, saveOpenedTabs } from "@/lib/api" +import type { AgentType, ConversationStatus, OpenedTab } from "@/lib/types" import { AGENT_DISPLAY_ORDER } from "@/lib/types" interface TabItemInternal { id: string kind: "conversation" + folderId: number conversationId: number | null /** The runtime session key used by ConversationRuntimeContext. * For new conversations this is a virtual (negative) ID that differs @@ -41,21 +38,28 @@ export type TabItem = TabItemInternal interface TabContextValue { tabs: TabItem[] activeTabId: string | null + tabsHydrated: boolean isTileMode: boolean openTab: ( + folderId: number, conversationId: number, agentType: AgentType, pin?: boolean, title?: string ) => void closeTab: (tabId: string) => void - closeConversationTab: (conversationId: number, agentType: AgentType) => void + closeConversationTab: ( + folderId: number, + conversationId: number, + agentType: AgentType + ) => void closeOtherTabs: (tabId: string) => void closeAllTabs: () => void + closeTabsByFolder: (folderId: number) => void switchTab: (tabId: string) => void pinTab: (tabId: string) => void toggleTileMode: () => void - openNewConversationTab: (workingDir: string) => void + openNewConversationTab: (folderId: number, workingDir: string) => void bindConversationTab: ( tabId: string, conversationId: number, @@ -67,6 +71,7 @@ interface TabContextValue { tabId: string, runtimeConversationId: number ) => void + setTabFolder: (tabId: string, folderId: number, workingDir: string) => void reorderTabs: (reorderedTabs: TabItem[]) => void onPreviewTabReplaced: (callback: (tabId: string) => void) => () => void } @@ -82,25 +87,31 @@ export function useTabContext() { } function makeConversationTabId( + folderId: number, agentType: AgentType, conversationId: number ): string { - return `conv-${agentType}-${conversationId}` + return `conv-${folderId}-${agentType}-${conversationId}` } function makeNewConversationTabId(): string { return `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } + function findTabIndexForConversation( tabs: TabItemInternal[], + folderId: number, agentType: AgentType, conversationId: number ): number { - const canonicalId = makeConversationTabId(agentType, conversationId) + const canonicalId = makeConversationTabId(folderId, agentType, conversationId) const idx = tabs.findIndex((t) => t.id === canonicalId) if (idx >= 0) return idx return tabs.findIndex( - (t) => t.conversationId === conversationId && t.agentType === agentType + (t) => + t.folderId === folderId && + t.conversationId === conversationId && + t.agentType === agentType ) } @@ -108,51 +119,18 @@ interface TabProviderProps { children: ReactNode } +const TILE_MODE_STORAGE_KEY = "workspace:tile-mode" + export function TabProvider({ children }: TabProviderProps) { const t = useTranslations("Folder.tabContext") const { activateConversationPane } = useWorkspaceContext() - const { - folder, - folderId, - selectedConversation, - selectConversation, - clearSelection, - startNewConversation, - cancelNewConversation, - conversations, - } = useFolderContext() + const { conversations, folders, setActiveFolderId } = useAppWorkspace() - const [rawTabs, setTabs] = useState(() => { - if (selectedConversation) { - const tabId = makeConversationTabId( - selectedConversation.agentType, - selectedConversation.id - ) - return [ - { - id: tabId, - kind: "conversation", - conversationId: selectedConversation.id, - agentType: selectedConversation.agentType, - title: t("loadingConversation"), - isPinned: true, - }, - ] - } - return [] - }) + const [rawTabs, setTabs] = useState([]) + const [activeTabId, setActiveTabId] = useState(null) + const [tabsHydrated, setTabsHydrated] = useState(false) - const [activeTabId, setActiveTabId] = useState(() => { - if (selectedConversation) { - return makeConversationTabId( - selectedConversation.agentType, - selectedConversation.id - ) - } - return null - }) - - // Refs for volatile state — used in callbacks to avoid re-creation + // Refs for volatile state const activeTabIdRef = useRef(activeTabId) useEffect(() => { activeTabIdRef.current = activeTabId @@ -163,11 +141,24 @@ export function TabProvider({ children }: TabProviderProps) { rawTabsRef.current = rawTabs }, [rawTabs]) + // Sync active tab's folderId up to AppWorkspaceProvider so derived + // consumers (ActiveFolderProvider, branch polling, etc.) reflect the + // currently-focused folder. + useEffect(() => { + const activeTab = rawTabs.find((t) => t.id === activeTabId) ?? null + setActiveFolderId(activeTab?.folderId ?? null) + }, [rawTabs, activeTabId, setActiveFolderId]) + const conversationsRef = useRef(conversations) useEffect(() => { conversationsRef.current = conversations }, [conversations]) + const foldersRef = useRef(folders) + useEffect(() => { + foldersRef.current = folders + }, [folders]) + // Callback set for preview tab replacement notifications const previewReplacedCallbacksRef = useRef(new Set<(tabId: string) => void>()) const onPreviewTabReplaced = useCallback( @@ -180,92 +171,75 @@ export function TabProvider({ children }: TabProviderProps) { [] ) - // Restore tabs from folder.opened_conversations when folder first loads - const [restoredFolderId, setRestoredFolderId] = useState(() => - selectedConversation ? folderId : null - ) - + // Hydrate from persisted opened_tabs on mount useEffect(() => { - if (!folder) return - if (restoredFolderId === folder.id) return - let cancelled = false - queueMicrotask(() => { - if (cancelled) return - - setRestoredFolderId(folder.id) - - const opened = folder.opened_conversations - if (opened.length === 0) return - - const restoredTabs: TabItemInternal[] = opened.map((oc) => ({ - id: makeConversationTabId(oc.agent_type, oc.conversation_id), - kind: "conversation", - conversationId: oc.conversation_id, - agentType: oc.agent_type, - title: t("loadingConversation"), - isPinned: oc.is_pinned, - })) - - setTabs(restoredTabs) - - const activeItem = opened.find((oc) => oc.is_active) - const target = activeItem ?? opened[0] - setActiveTabId( - makeConversationTabId(target.agent_type, target.conversation_id) - ) - }) - + void (async () => { + try { + const items = await listOpenedTabs() + if (cancelled) return + const restored: TabItemInternal[] = items.map((it) => ({ + id: + it.conversation_id != null + ? makeConversationTabId( + it.folder_id, + it.agent_type, + it.conversation_id + ) + : makeNewConversationTabId(), + kind: "conversation", + folderId: it.folder_id, + conversationId: it.conversation_id, + agentType: it.agent_type, + title: t("loadingConversation"), + isPinned: it.is_pinned, + })) + setTabs(restored) + const active = items.find((it) => it.is_active) + if (active) { + const activeRestored = restored.find( + (r) => + r.folderId === active.folder_id && + r.agentType === active.agent_type && + r.conversationId === active.conversation_id + ) + if (activeRestored) setActiveTabId(activeRestored.id) + } else if (restored.length > 0) { + setActiveTabId(restored[0].id) + } + } catch (err) { + console.error("[TabProvider] listOpenedTabs failed:", err) + } finally { + if (!cancelled) setTabsHydrated(true) + } + })() return () => { cancelled = true } - }, [folder, restoredFolderId, t]) - - // Sync restored active tab to FolderProvider (deferred to avoid - // updating parent during child render) - const prevRestoredIdRef = useRef(restoredFolderId) - useEffect(() => { - if (restoredFolderId === prevRestoredIdRef.current) return - prevRestoredIdRef.current = restoredFolderId - - if (!folder || folder.opened_conversations.length === 0) return - const opened = folder.opened_conversations - const target = opened.find((oc) => oc.is_active) ?? opened[0] - selectConversation(target.conversation_id, target.agent_type) - }, [restoredFolderId, folder, selectConversation]) + }, [t]) // Debounced save to DB const saveTimerRef = useRef | null>(null) - const skipSaveRef = useRef(true) // skip saving until first restore completes useEffect(() => { - // Skip the initial render and restoration phase - if (skipSaveRef.current) { - if (restoredFolderId != null) { - skipSaveRef.current = false - } - return - } + if (!tabsHydrated) return if (saveTimerRef.current) { clearTimeout(saveTimerRef.current) } saveTimerRef.current = setTimeout(() => { - const items: OpenedConversation[] = rawTabs - .filter( - (t): t is TabItemInternal & { conversationId: number } => - t.conversationId != null - ) - .map((t, i) => ({ - conversation_id: t.conversationId, - agent_type: t.agentType, - position: i, - is_active: t.id === activeTabId, - is_pinned: t.isPinned, - })) + const items: OpenedTab[] = rawTabs.map((tab, i) => ({ + id: 0, + folder_id: tab.folderId, + conversation_id: tab.conversationId, + agent_type: tab.agentType, + position: i, + is_active: tab.id === activeTabId, + is_pinned: tab.isPinned, + })) - saveFolderOpenedConversations(folderId, items).catch(() => { + saveOpenedTabs(items).catch(() => { // Silently ignore save errors }) }, 500) @@ -275,13 +249,13 @@ export function TabProvider({ children }: TabProviderProps) { clearTimeout(saveTimerRef.current) } } - }, [rawTabs, activeTabId, folderId, restoredFolderId]) + }, [rawTabs, activeTabId, tabsHydrated]) // Pre-index conversations for O(1) lookup in tabs derivation const conversationMap = useMemo(() => { const m = new Map() for (const c of conversations) { - m.set(`${c.agent_type}-${c.id}`, c) + m.set(`${c.folder_id}-${c.agent_type}-${c.id}`, c) } return m }, [conversations]) @@ -292,7 +266,7 @@ export function TabProvider({ children }: TabProviderProps) { return rawTabs.map((tab) => { if (tab.conversationId != null) { const conv = conversationMap.get( - `${tab.agentType}-${tab.conversationId}` + `${tab.folderId}-${tab.agentType}-${tab.conversationId}` ) if (conv) { const newTitle = conv.title || t("untitledConversation") @@ -306,36 +280,9 @@ export function TabProvider({ children }: TabProviderProps) { }) }, [rawTabs, conversationMap, t]) - const syncFolderContext = useCallback( - (tab: TabItem | null) => { - if (!tab) { - clearSelection() - cancelNewConversation() - return - } - if (tab.conversationId != null) { - selectConversation(tab.conversationId, tab.agentType) - } else { - const workingDir = tab.workingDir ?? folder?.path - if (!workingDir) { - clearSelection() - cancelNewConversation() - return - } - startNewConversation(workingDir) - } - }, - [ - folder?.path, - selectConversation, - clearSelection, - startNewConversation, - cancelNewConversation, - ] - ) - const openTab = useCallback( ( + folderId: number, conversationId: number, agentType: AgentType, pin = false, @@ -347,6 +294,7 @@ export function TabProvider({ children }: TabProviderProps) { setTabs((prev) => { const existingIndex = findTabIndexForConversation( prev, + folderId, agentType, conversationId ) @@ -364,19 +312,22 @@ export function TabProvider({ children }: TabProviderProps) { return prev } - // Resolve title from conversations list (via ref) const resolvedTitle = title ?? conversationsRef.current.find( - (c) => c.id === conversationId && c.agent_type === agentType + (c) => + c.id === conversationId && + c.agent_type === agentType && + c.folder_id === folderId )?.title ?? t("untitledConversation") - const tabId = makeConversationTabId(agentType, conversationId) + const tabId = makeConversationTabId(folderId, agentType, conversationId) activateTabId = tabId const newTab: TabItemInternal = { id: tabId, kind: "conversation", + folderId, conversationId, agentType, title: resolvedTitle, @@ -387,7 +338,6 @@ export function TabProvider({ children }: TabProviderProps) { return [...prev, newTab] } - // Preview (not pinned): replace existing preview tab const previewIndex = prev.findIndex((t) => !t.isPinned) if (previewIndex >= 0) { replacedPreviewTabId = prev[previewIndex].id @@ -399,7 +349,6 @@ export function TabProvider({ children }: TabProviderProps) { return [...prev, newTab] }) - // Notify listeners about the replaced preview tab if (replacedPreviewTabId) { for (const cb of previewReplacedCallbacksRef.current) { cb(replacedPreviewTabId) @@ -409,30 +358,36 @@ export function TabProvider({ children }: TabProviderProps) { if (activateTabId) { setActiveTabId(activateTabId) } - selectConversation(conversationId, agentType) activateConversationPane() }, - [activateConversationPane, selectConversation, t] + [activateConversationPane, t] ) const makeReplacementDraftTab = useCallback( - (preferred?: TabItemInternal): TabItemInternal => ({ - id: makeNewConversationTabId(), - kind: "conversation", - conversationId: null, - agentType: AGENT_DISPLAY_ORDER[0], - title: t("newConversation"), - isPinned: true, - workingDir: preferred?.workingDir ?? folder?.path, - }), - [folder?.path, t] + (preferred?: TabItemInternal): TabItemInternal => { + const folderId = preferred?.folderId ?? foldersRef.current[0]?.id ?? 0 + const workingDir = + preferred?.workingDir ?? + foldersRef.current.find((f) => f.id === folderId)?.path ?? + "" + return { + id: makeNewConversationTabId(), + kind: "conversation", + folderId, + conversationId: null, + agentType: AGENT_DISPLAY_ORDER[0], + title: t("newConversation"), + isPinned: true, + workingDir, + } + }, + [t] ) - const tileModeKey = `folder:${folderId}:tile-mode` const [isTileMode, setIsTileMode] = useState(() => { if (typeof window === "undefined") return false try { - return localStorage.getItem(tileModeKey) === "true" + return localStorage.getItem(TILE_MODE_STORAGE_KEY) === "true" } catch { return false } @@ -440,15 +395,16 @@ export function TabProvider({ children }: TabProviderProps) { useEffect(() => { try { - localStorage.setItem(tileModeKey, String(isTileMode)) + localStorage.setItem(TILE_MODE_STORAGE_KEY, String(isTileMode)) } catch { /* ignore */ } - }, [isTileMode, tileModeKey]) + }, [isTileMode]) const closeTab = useCallback( (tabId: string) => { let neighborToSync: TabItemInternal | undefined + let shouldReplaceWithEmpty = false setTabs((prev) => { const index = prev.findIndex((t) => t.id === tabId) @@ -458,14 +414,16 @@ export function TabProvider({ children }: TabProviderProps) { const next = prev.filter((t) => t.id !== tabId) if (next.length === 0) { + if (foldersRef.current.length === 0) { + shouldReplaceWithEmpty = true + return [] + } const replacementTab = makeReplacementDraftTab(closingTab) neighborToSync = replacementTab return [replacementTab] } - // If closing the active tab, pick a neighbor to activate if (tabId === activeTabIdRef.current) { - // Prefer right neighbor, then left const newIndex = Math.min(index, next.length - 1) neighborToSync = next[newIndex] } @@ -473,22 +431,26 @@ export function TabProvider({ children }: TabProviderProps) { return next }) - // Sync folder context outside the updater to avoid - // updating FolderProvider state during TabProvider render + if (shouldReplaceWithEmpty) { + setActiveTabId(null) + return + } + if (neighborToSync) { setActiveTabId(neighborToSync.id) - syncFolderContext(neighborToSync) activateConversationPane() } }, - [activateConversationPane, makeReplacementDraftTab, syncFolderContext] + [activateConversationPane, makeReplacementDraftTab] ) const closeConversationTab = useCallback( - (conversationId: number, agentType: AgentType) => { + (folderId: number, conversationId: number, agentType: AgentType) => { const target = rawTabsRef.current.find( (tab) => - tab.conversationId === conversationId && tab.agentType === agentType + tab.folderId === folderId && + tab.conversationId === conversationId && + tab.agentType === agentType ) if (!target) return closeTab(target.id) @@ -496,20 +458,13 @@ export function TabProvider({ children }: TabProviderProps) { [closeTab] ) - const closeOtherTabs = useCallback( - (tabId: string) => { - setTabs((prev) => { - const kept = prev.filter((t) => t.id === tabId) - return kept.length === prev.length ? prev : kept - }) - const tab = rawTabsRef.current.find((t) => t.id === tabId) - if (tab) { - setActiveTabId(tabId) - syncFolderContext(tab) - } - }, - [syncFolderContext] - ) + const closeOtherTabs = useCallback((tabId: string) => { + setTabs((prev) => { + const kept = prev.filter((t) => t.id === tabId) + return kept.length === prev.length ? prev : kept + }) + setActiveTabId(tabId) + }, []) const closeAllTabs = useCallback(() => { const seedTab = @@ -519,12 +474,33 @@ export function TabProvider({ children }: TabProviderProps) { rawTabsRef.current.find((t) => t.id === activeTabIdRef.current) ?? rawTabsRef.current[0] + if (foldersRef.current.length === 0) { + setTabs([]) + setActiveTabId(null) + return + } + const replacementTab = makeReplacementDraftTab(seedTab) setTabs([replacementTab]) setActiveTabId(replacementTab.id) - syncFolderContext(replacementTab) activateConversationPane() - }, [activateConversationPane, makeReplacementDraftTab, syncFolderContext]) + }, [activateConversationPane, makeReplacementDraftTab]) + + const closeTabsByFolder = useCallback((folderId: number) => { + setTabs((prev) => { + const remaining = prev.filter((t) => t.folderId !== folderId) + if (remaining.length === prev.length) return prev + + // If active tab is being closed, move to first remaining tab + const currentActive = activeTabIdRef.current + const stillActive = + currentActive != null && remaining.some((t) => t.id === currentActive) + if (!stillActive) { + setActiveTabId(remaining.length > 0 ? remaining[0].id : null) + } + return remaining + }) + }, []) const switchTab = useCallback( (tabId: string) => { @@ -532,10 +508,9 @@ export function TabProvider({ children }: TabProviderProps) { if (!tab) return setActiveTabId(tabId) - syncFolderContext(tab) activateConversationPane() }, - [activateConversationPane, syncFolderContext] + [activateConversationPane] ) const pinTab = useCallback((tabId: string) => { @@ -554,13 +529,13 @@ export function TabProvider({ children }: TabProviderProps) { ) const openNewConversationTab = useCallback( - (workingDir: string) => { + (folderId: number, workingDir: string) => { + // Reuse existing draft tab for the same folder if present const existingTab = rawTabsRef.current.find( - (t) => t.conversationId == null + (t) => t.conversationId == null && t.folderId === folderId ) if (existingTab) { - // Update workingDir if it differs from the request if (existingTab.workingDir !== workingDir) { setTabs((prev) => prev.map((t) => @@ -569,7 +544,6 @@ export function TabProvider({ children }: TabProviderProps) { ) } setActiveTabId(existingTab.id) - syncFolderContext(existingTab) activateConversationPane() return } @@ -579,6 +553,7 @@ export function TabProvider({ children }: TabProviderProps) { const newTab: TabItemInternal = { id: tabId, kind: "conversation", + folderId, conversationId: null, agentType, title: t("newConversation"), @@ -588,10 +563,9 @@ export function TabProvider({ children }: TabProviderProps) { setTabs((prev) => [...prev, newTab]) setActiveTabId(tabId) - startNewConversation(workingDir) activateConversationPane() }, - [activateConversationPane, startNewConversation, syncFolderContext, t] + [activateConversationPane, t] ) const bindConversationTab = useCallback( @@ -606,7 +580,7 @@ export function TabProvider({ children }: TabProviderProps) { setTabs((prev) => prev.flatMap((tab) => { if (tab.id === tabId) { - const nextTab = { + const nextTab: TabItemInternal = { ...tab, conversationId, agentType, @@ -617,6 +591,7 @@ export function TabProvider({ children }: TabProviderProps) { } if ( + tab.folderId === tab.folderId && tab.conversationId === conversationId && tab.agentType === agentType ) { @@ -631,15 +606,9 @@ export function TabProvider({ children }: TabProviderProps) { ) if (nextActiveTabId) { setActiveTabId(nextActiveTabId) - const target = rawTabsRef.current.find( - (tab) => tab.id === nextActiveTabId - ) - if (target) { - syncFolderContext(target) - } } }, - [syncFolderContext] + [] ) const setTabRuntimeConversationId = useCallback( @@ -657,40 +626,57 @@ export function TabProvider({ children }: TabProviderProps) { [] ) + const setTabFolder = useCallback( + (tabId: string, folderId: number, workingDir: string) => { + setTabs((prev) => + prev.map((tab) => + tab.id === tabId ? { ...tab, folderId, workingDir } : tab + ) + ) + }, + [] + ) + const value = useMemo( () => ({ tabs, activeTabId, + tabsHydrated, isTileMode, openTab, closeTab, closeConversationTab, closeOtherTabs, closeAllTabs, + closeTabsByFolder, switchTab, pinTab, toggleTileMode, openNewConversationTab, bindConversationTab, setTabRuntimeConversationId, + setTabFolder, reorderTabs, onPreviewTabReplaced, }), [ tabs, activeTabId, + tabsHydrated, isTileMode, openTab, closeTab, closeConversationTab, closeOtherTabs, closeAllTabs, + closeTabsByFolder, switchTab, pinTab, toggleTileMode, openNewConversationTab, bindConversationTab, setTabRuntimeConversationId, + setTabFolder, reorderTabs, onPreviewTabReplaced, ] diff --git a/src/contexts/terminal-context.tsx b/src/contexts/terminal-context.tsx index 34259db..19fc429 100644 --- a/src/contexts/terminal-context.tsx +++ b/src/contexts/terminal-context.tsx @@ -12,12 +12,13 @@ import { } from "react" import { terminalKill } from "@/lib/api" import { randomUUID } from "@/lib/utils" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" export interface TerminalTab { id: string + folderId: number title: string workingDir: string initialCommand?: string @@ -65,7 +66,7 @@ export function useTerminalContext() { } export function TerminalProvider({ children }: { children: ReactNode }) { - const { folder } = useFolderContext() + const { activeFolder, activeFolderId } = useActiveFolder() const { shortcuts } = useShortcutSettings() const [isOpen, setIsOpen] = useState(false) const [height, setHeightState] = useState(DEFAULT_HEIGHT) @@ -80,7 +81,8 @@ export function TerminalProvider({ children }: { children: ReactNode }) { tabsRef.current = tabs }, [tabs]) - const folderPath = folder?.path ?? "" + const folderPath = activeFolder?.path ?? "" + const currentFolderId = activeFolderId ?? 0 const markTerminalExited = useCallback((id: string) => { setExitedTerminals((prev) => { @@ -122,6 +124,7 @@ export function TerminalProvider({ children }: { children: ReactNode }) { return [ { id: autoId, + folderId: currentFolderId, title: `Terminal ${nextCounter}`, workingDir: folderPath, }, @@ -133,7 +136,7 @@ export function TerminalProvider({ children }: { children: ReactNode }) { if (!folderPath) return null return autoId }) - }, [folderPath]) + }, [folderPath, currentFolderId]) const createTerminalWithCommand = useCallback( async (title: string, command: string) => { @@ -145,13 +148,19 @@ export function TerminalProvider({ children }: { children: ReactNode }) { tabCounterRef.current += 1 setTabs((prev) => [ ...prev, - { id, title, workingDir: folderPath, initialCommand: command }, + { + id, + folderId: currentFolderId, + title, + workingDir: folderPath, + initialCommand: command, + }, ]) setActiveTabId(id) return id }, - [folderPath] + [folderPath, currentFolderId] ) const createTerminalInDirectory = useCallback( @@ -165,13 +174,18 @@ export function TerminalProvider({ children }: { children: ReactNode }) { const defaultTitle = `Terminal ${tabCounterRef.current}` setTabs((prev) => [ ...prev, - { id, title: title ?? defaultTitle, workingDir }, + { + id, + folderId: currentFolderId, + title: title ?? defaultTitle, + workingDir, + }, ]) setActiveTabId(id) return id }, - [] + [currentFolderId] ) const createTerminal = useCallback(async () => { diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index 0c41ccc..47ca9eb 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -11,7 +11,7 @@ import { type ReactNode, } from "react" import { useTranslations } from "next-intl" -import { useFolderContext } from "@/contexts/folder-context" +import { useActiveFolder } from "@/contexts/active-folder-context" import { gitDiff, gitDiffWithBranch, @@ -208,12 +208,11 @@ interface WorkspaceProviderProps { export function WorkspaceProvider({ children }: WorkspaceProviderProps) { const t = useTranslations("Folder.workspaceContext") - const { folder, folderId } = useFolderContext() - const folderPath = folder?.path - const storageKey = useMemo( - () => `folder:${folderId}:workspace-mode`, - [folderId] - ) + const { activeFolder, activeFolderId } = useActiveFolder() + const folderPath = activeFolder?.path + const storageKey = "workspace:mode" + /* activeFolderId used in effect below to reset file tabs on folder switch */ + void activeFolderId const [mode, setModeState] = useState(DEFAULT_WORKSPACE_MODE) const [activePane, setActivePaneState] = useState("conversation") @@ -247,6 +246,17 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { setRestored(true) }, [storageKey]) + // Clear file tabs when the active folder changes — files are not persisted + // across folder switches in the workspace model. + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect */ + setFileTabs([]) + setActiveFileTabId(null) + setPreviewFileTabIds(new Set()) + setPendingFileReveal(null) + /* eslint-enable react-hooks/set-state-in-effect */ + }, [activeFolderId]) + useEffect(() => { if (!restored) return savePersistedWorkspaceMode(storageKey, mode) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 8ee11ef..b9f59c7 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -825,7 +825,24 @@ "noNewSessionsFound": "لم يتم العثور على جلسات جديدة (تم تخطي {skipped})", "importFailed": "فشل الاستيراد: {message}", "reviewCompleted": "تم وضع علامة مكتمل على {count, plural, one {# جلسة مراجعة} other {# جلسات مراجعة}}", - "completeReviewFailed": "فشل إكمال جلسات المراجعة: {message}" + "completeReviewFailed": "فشل إكمال جلسات المراجعة: {message}", + "folderOpened": "تم فتح المجلد {name}", + "folderRemoved": "تمت إزالة المجلد {name}", + "openFolderFailed": "فشل فتح المجلد", + "removeFolderFailed": "فشل إزالة المجلد: {message}" + }, + "statsLabel": "{folders} مجلدات · {convos} محادثة", + "openFolder": "فتح مجلد", + "searchPlaceholder": "بحث عن محادثات...", + "viewFlat": "عرض مسطح", + "viewGrouped": "تجميع حسب المجلد", + "noMatchingConversations": "لا توجد محادثات مطابقة", + "removeFolderConfirmTitle": "إزالة المجلد من مساحة العمل؟", + "removeFolderConfirmDescription": "إزالة \"{name}\" من مساحة العمل؟ سيتم إغلاق علامات التبويب والمحطات المرتبطة.", + "folderHeaderMenu": { + "focus": "تركيز", + "closeFolderTabs": "إغلاق جميع علامات تبويب هذا المجلد", + "removeFromWorkspace": "إزالة من مساحة العمل" } }, "conversation": { @@ -960,7 +977,9 @@ "closeOthers": "إغلاق البقية", "closeAll": "إغلاق الكل", "tileDisplay": "عرض متجانب", - "untileDisplay": "إلغاء التجانب" + "untileDisplay": "إلغاء التجانب", + "closeFolderTabs": "إغلاق جميع علامات التبويب لـ {folder}", + "revealInSidebar": "إظهار في الشريط الجانبي" }, "fileWorkspace": { "files": "الملفات", @@ -976,7 +995,8 @@ "close": "إغلاق", "closeOthers": "إغلاق البقية", "closeAll": "إغلاق الكل", - "hideTerminal": "إخفاء الطرفية ({shortcut})" + "hideTerminal": "إخفاء الطرفية ({shortcut})", + "openFolderFirst": "افتح مجلدا أولا" }, "sessionFiles": { "currentResponse": "الاستجابة الحالية", @@ -1792,6 +1812,25 @@ "hunkLabel": "مقطع {index}", "loadingHunk": "جارٍ تحميل hunk...", "noDiffData": "لا توجد بيانات diff" + }, + "conversationContextBar": { + "searchFolder": "Search folder...", + "searchBranch": "Search branch...", + "noFolders": "No folders", + "noBranches": "No branches", + "noBranch": "(no branch)", + "openNewFolder": "Open folder from disk...", + "cancel": "Cancel", + "create": "Create", + "commit": "Commit", + "push": "Push", + "merge": "Merge", + "toasts": { + "folderChanged": "Switched to {name}", + "openFolderFailed": "Failed to open folder", + "openStashFailed": "Failed to open stash window", + "openMergeFailed": "Failed to open merge window" + } } }, "ProjectBoot": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 577ac6d..fd61846 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -825,7 +825,24 @@ "noNewSessionsFound": "Keine neuen Sitzungen gefunden ({skipped} übersprungen)", "importFailed": "Import fehlgeschlagen: {message}", "reviewCompleted": "{count, plural, one {# Review-Sitzung} other {# Review-Sitzungen}} als abgeschlossen markiert", - "completeReviewFailed": "Review-Sitzungen konnten nicht abgeschlossen werden: {message}" + "completeReviewFailed": "Review-Sitzungen konnten nicht abgeschlossen werden: {message}", + "folderOpened": "Ordner {name} geöffnet", + "folderRemoved": "Ordner {name} entfernt", + "openFolderFailed": "Ordner konnte nicht geöffnet werden", + "removeFolderFailed": "Ordner konnte nicht entfernt werden: {message}" + }, + "statsLabel": "{folders} Ordner · {convos} Konversationen", + "openFolder": "Ordner öffnen", + "searchPlaceholder": "Konversationen suchen...", + "viewFlat": "Flache Ansicht", + "viewGrouped": "Nach Ordner gruppieren", + "noMatchingConversations": "Keine passenden Konversationen", + "removeFolderConfirmTitle": "Ordner aus Arbeitsbereich entfernen?", + "removeFolderConfirmDescription": "\"{name}\" aus dem Arbeitsbereich entfernen? Zugehörige Tabs und Terminals werden geschlossen.", + "folderHeaderMenu": { + "focus": "Fokussieren", + "closeFolderTabs": "Alle Tabs dieses Ordners schließen", + "removeFromWorkspace": "Aus Arbeitsbereich entfernen" } }, "conversation": { @@ -960,7 +977,9 @@ "closeOthers": "Andere schließen", "closeAll": "Alle schließen", "tileDisplay": "Kachelansicht", - "untileDisplay": "Kachel beenden" + "untileDisplay": "Kachel beenden", + "closeFolderTabs": "Alle Tabs von {folder} schließen", + "revealInSidebar": "In Seitenleiste anzeigen" }, "fileWorkspace": { "files": "Dateien", @@ -976,7 +995,8 @@ "close": "Schließen", "closeOthers": "Andere schließen", "closeAll": "Alle schließen", - "hideTerminal": "Terminal ausblenden ({shortcut})" + "hideTerminal": "Terminal ausblenden ({shortcut})", + "openFolderFirst": "Öffnen Sie zuerst einen Ordner" }, "sessionFiles": { "currentResponse": "Aktuelle Antwort", @@ -1792,6 +1812,25 @@ "hunkLabel": "Block {index}", "loadingHunk": "Hunk wird geladen...", "noDiffData": "Keine Diff-Daten" + }, + "conversationContextBar": { + "searchFolder": "Search folder...", + "searchBranch": "Search branch...", + "noFolders": "No folders", + "noBranches": "No branches", + "noBranch": "(no branch)", + "openNewFolder": "Open folder from disk...", + "cancel": "Cancel", + "create": "Create", + "commit": "Commit", + "push": "Push", + "merge": "Merge", + "toasts": { + "folderChanged": "Switched to {name}", + "openFolderFailed": "Failed to open folder", + "openStashFailed": "Failed to open stash window", + "openMergeFailed": "Failed to open merge window" + } } }, "ProjectBoot": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index a296b7e..e156966 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -825,7 +825,24 @@ "noNewSessionsFound": "No new sessions found (skipped {skipped})", "importFailed": "Import failed: {message}", "reviewCompleted": "Marked {count, plural, one {# review session} other {# review sessions}} as completed", - "completeReviewFailed": "Failed to complete review sessions: {message}" + "completeReviewFailed": "Failed to complete review sessions: {message}", + "folderOpened": "Opened folder {name}", + "folderRemoved": "Removed folder {name}", + "openFolderFailed": "Failed to open folder", + "removeFolderFailed": "Failed to remove folder: {message}" + }, + "statsLabel": "{folders} folders · {convos} conversations", + "openFolder": "Open Folder", + "searchPlaceholder": "Search conversations...", + "viewFlat": "Flat view", + "viewGrouped": "Grouped by folder", + "noMatchingConversations": "No matching conversations", + "removeFolderConfirmTitle": "Remove folder from workspace?", + "removeFolderConfirmDescription": "Remove \"{name}\" from the workspace? Its tabs and terminals will close.", + "folderHeaderMenu": { + "focus": "Focus", + "closeFolderTabs": "Close all tabs of this folder", + "removeFromWorkspace": "Remove from workspace" } }, "conversation": { @@ -960,7 +977,9 @@ "closeOthers": "Close Others", "closeAll": "Close All", "tileDisplay": "Tile Display", - "untileDisplay": "Exit Tile" + "untileDisplay": "Exit Tile", + "closeFolderTabs": "Close all tabs of {folder}", + "revealInSidebar": "Reveal in sidebar" }, "fileWorkspace": { "files": "Files", @@ -976,7 +995,8 @@ "close": "Close", "closeOthers": "Close Others", "closeAll": "Close All", - "hideTerminal": "Hide Terminal ({shortcut})" + "hideTerminal": "Hide Terminal ({shortcut})", + "openFolderFirst": "Open a folder first" }, "sessionFiles": { "currentResponse": "Current response", @@ -1792,6 +1812,25 @@ "hunkLabel": "Hunk {index}", "loadingHunk": "Loading hunk...", "noDiffData": "No diff data" + }, + "conversationContextBar": { + "searchFolder": "Search folder...", + "searchBranch": "Search branch...", + "noFolders": "No folders", + "noBranches": "No branches", + "noBranch": "(no branch)", + "openNewFolder": "Open folder from disk...", + "cancel": "Cancel", + "create": "Create", + "commit": "Commit", + "push": "Push", + "merge": "Merge", + "toasts": { + "folderChanged": "Switched to {name}", + "openFolderFailed": "Failed to open folder", + "openStashFailed": "Failed to open stash window", + "openMergeFailed": "Failed to open merge window" + } } }, "ProjectBoot": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 2ca4677..ba6e05e 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -825,7 +825,24 @@ "noNewSessionsFound": "No se encontraron sesiones nuevas (omitidas {skipped})", "importFailed": "Error al importar: {message}", "reviewCompleted": "Se marcaron como completadas {count, plural, one {# sesión en revisión} other {# sesiones en revisión}}", - "completeReviewFailed": "Error al completar sesiones en revisión: {message}" + "completeReviewFailed": "Error al completar sesiones en revisión: {message}", + "folderOpened": "Carpeta {name} abierta", + "folderRemoved": "Carpeta {name} eliminada", + "openFolderFailed": "Error al abrir carpeta", + "removeFolderFailed": "Error al eliminar carpeta: {message}" + }, + "statsLabel": "{folders} carpetas · {convos} conversaciones", + "openFolder": "Abrir carpeta", + "searchPlaceholder": "Buscar conversaciones...", + "viewFlat": "Vista plana", + "viewGrouped": "Agrupar por carpeta", + "noMatchingConversations": "No hay conversaciones coincidentes", + "removeFolderConfirmTitle": "¿Eliminar carpeta del espacio de trabajo?", + "removeFolderConfirmDescription": "¿Eliminar \"{name}\" del espacio de trabajo? Sus pestañas y terminales se cerrarán.", + "folderHeaderMenu": { + "focus": "Enfocar", + "closeFolderTabs": "Cerrar todas las pestañas de esta carpeta", + "removeFromWorkspace": "Quitar del espacio de trabajo" } }, "conversation": { @@ -960,7 +977,9 @@ "closeOthers": "Cerrar otros", "closeAll": "Cerrar todo", "tileDisplay": "Vista en mosaico", - "untileDisplay": "Salir de mosaico" + "untileDisplay": "Salir de mosaico", + "closeFolderTabs": "Cerrar todas las pestañas de {folder}", + "revealInSidebar": "Mostrar en la barra lateral" }, "fileWorkspace": { "files": "Archivos", @@ -976,7 +995,8 @@ "close": "Cerrar", "closeOthers": "Cerrar otros", "closeAll": "Cerrar todo", - "hideTerminal": "Ocultar terminal ({shortcut})" + "hideTerminal": "Ocultar terminal ({shortcut})", + "openFolderFirst": "Abre primero una carpeta" }, "sessionFiles": { "currentResponse": "Respuesta actual", @@ -1792,6 +1812,25 @@ "hunkLabel": "Bloque {index}", "loadingHunk": "Cargando hunk...", "noDiffData": "Sin datos de diff" + }, + "conversationContextBar": { + "searchFolder": "Search folder...", + "searchBranch": "Search branch...", + "noFolders": "No folders", + "noBranches": "No branches", + "noBranch": "(no branch)", + "openNewFolder": "Open folder from disk...", + "cancel": "Cancel", + "create": "Create", + "commit": "Commit", + "push": "Push", + "merge": "Merge", + "toasts": { + "folderChanged": "Switched to {name}", + "openFolderFailed": "Failed to open folder", + "openStashFailed": "Failed to open stash window", + "openMergeFailed": "Failed to open merge window" + } } }, "ProjectBoot": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 72cf15e..8b4173c 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -825,7 +825,24 @@ "noNewSessionsFound": "Aucune nouvelle session trouvée ({skipped} ignorée(s))", "importFailed": "Échec de l’import : {message}", "reviewCompleted": "{count, plural, one {# session en revue} other {# sessions en revue}} marquée(s) comme terminée(s)", - "completeReviewFailed": "Échec de la finalisation des sessions en revue : {message}" + "completeReviewFailed": "Échec de la finalisation des sessions en revue : {message}", + "folderOpened": "Dossier {name} ouvert", + "folderRemoved": "Dossier {name} retiré", + "openFolderFailed": "Échec de l'ouverture du dossier", + "removeFolderFailed": "Échec de la suppression du dossier : {message}" + }, + "statsLabel": "{folders} dossiers · {convos} conversations", + "openFolder": "Ouvrir le dossier", + "searchPlaceholder": "Rechercher des conversations...", + "viewFlat": "Vue à plat", + "viewGrouped": "Grouper par dossier", + "noMatchingConversations": "Aucune conversation correspondante", + "removeFolderConfirmTitle": "Retirer le dossier de l'espace de travail ?", + "removeFolderConfirmDescription": "Retirer \"{name}\" de l'espace de travail ? Les onglets et terminaux associés seront fermés.", + "folderHeaderMenu": { + "focus": "Focaliser", + "closeFolderTabs": "Fermer tous les onglets de ce dossier", + "removeFromWorkspace": "Retirer de l'espace de travail" } }, "conversation": { @@ -960,7 +977,9 @@ "closeOthers": "Fermer les autres", "closeAll": "Tout fermer", "tileDisplay": "Affichage en mosaïque", - "untileDisplay": "Quitter la mosaïque" + "untileDisplay": "Quitter la mosaïque", + "closeFolderTabs": "Fermer tous les onglets de {folder}", + "revealInSidebar": "Afficher dans la barre latérale" }, "fileWorkspace": { "files": "Fichiers", @@ -976,7 +995,8 @@ "close": "Fermer", "closeOthers": "Fermer les autres", "closeAll": "Tout fermer", - "hideTerminal": "Masquer le terminal ({shortcut})" + "hideTerminal": "Masquer le terminal ({shortcut})", + "openFolderFirst": "Ouvrez d'abord un dossier" }, "sessionFiles": { "currentResponse": "Réponse actuelle", @@ -1792,6 +1812,25 @@ "hunkLabel": "Bloc {index}", "loadingHunk": "Chargement du hunk...", "noDiffData": "Aucune donnée diff" + }, + "conversationContextBar": { + "searchFolder": "Search folder...", + "searchBranch": "Search branch...", + "noFolders": "No folders", + "noBranches": "No branches", + "noBranch": "(no branch)", + "openNewFolder": "Open folder from disk...", + "cancel": "Cancel", + "create": "Create", + "commit": "Commit", + "push": "Push", + "merge": "Merge", + "toasts": { + "folderChanged": "Switched to {name}", + "openFolderFailed": "Failed to open folder", + "openStashFailed": "Failed to open stash window", + "openMergeFailed": "Failed to open merge window" + } } }, "ProjectBoot": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 3a421f9..743957c 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -825,7 +825,24 @@ "noNewSessionsFound": "新しいセッションは見つかりませんでした({skipped} 件をスキップ)", "importFailed": "インポートに失敗しました: {message}", "reviewCompleted": "{count, plural, one {# 件のレビューセッション} other {# 件のレビューセッション}} を完了にしました", - "completeReviewFailed": "レビューセッションの完了処理に失敗しました: {message}" + "completeReviewFailed": "レビューセッションの完了処理に失敗しました: {message}", + "folderOpened": "フォルダ {name} を開きました", + "folderRemoved": "フォルダ {name} を削除しました", + "openFolderFailed": "フォルダを開けませんでした", + "removeFolderFailed": "フォルダの削除に失敗しました: {message}" + }, + "statsLabel": "{folders} フォルダ · {convos} 会話", + "openFolder": "フォルダを開く", + "searchPlaceholder": "会話を検索...", + "viewFlat": "フラット表示", + "viewGrouped": "フォルダでグループ化", + "noMatchingConversations": "一致する会話がありません", + "removeFolderConfirmTitle": "このフォルダをワークスペースから削除しますか?", + "removeFolderConfirmDescription": "\"{name}\" をワークスペースから削除しますか?関連するタブとターミナルが閉じられます。", + "folderHeaderMenu": { + "focus": "フォーカス", + "closeFolderTabs": "このフォルダのすべてのタブを閉じる", + "removeFromWorkspace": "ワークスペースから削除" } }, "conversation": { @@ -960,7 +977,9 @@ "closeOthers": "他を閉じる", "closeAll": "すべて閉じる", "tileDisplay": "タイル表示", - "untileDisplay": "タイル解除" + "untileDisplay": "タイル解除", + "closeFolderTabs": "{folder} のすべてのタブを閉じる", + "revealInSidebar": "サイドバーで表示" }, "fileWorkspace": { "files": "ファイル", @@ -976,7 +995,8 @@ "close": "閉じる", "closeOthers": "他を閉じる", "closeAll": "すべて閉じる", - "hideTerminal": "ターミナルを隠す ({shortcut})" + "hideTerminal": "ターミナルを隠す ({shortcut})", + "openFolderFirst": "先にフォルダを開いてください" }, "sessionFiles": { "currentResponse": "現在の応答", @@ -1792,6 +1812,25 @@ "hunkLabel": "ハンク {index}", "loadingHunk": "Hunk を読み込み中...", "noDiffData": "Diff データがありません" + }, + "conversationContextBar": { + "searchFolder": "Search folder...", + "searchBranch": "Search branch...", + "noFolders": "No folders", + "noBranches": "No branches", + "noBranch": "(no branch)", + "openNewFolder": "Open folder from disk...", + "cancel": "Cancel", + "create": "Create", + "commit": "Commit", + "push": "Push", + "merge": "Merge", + "toasts": { + "folderChanged": "Switched to {name}", + "openFolderFailed": "Failed to open folder", + "openStashFailed": "Failed to open stash window", + "openMergeFailed": "Failed to open merge window" + } } }, "ProjectBoot": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 3b6802b..d7b8149 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -825,7 +825,24 @@ "noNewSessionsFound": "새 세션이 없습니다 ({skipped}개 건너뜀)", "importFailed": "가져오기 실패: {message}", "reviewCompleted": "{count, plural, one {#개 검토 세션} other {#개 검토 세션}}을 완료로 표시했습니다", - "completeReviewFailed": "검토 세션 완료 처리 실패: {message}" + "completeReviewFailed": "검토 세션 완료 처리 실패: {message}", + "folderOpened": "폴더 {name}을(를) 열었습니다", + "folderRemoved": "폴더 {name}을(를) 제거했습니다", + "openFolderFailed": "폴더를 열 수 없습니다", + "removeFolderFailed": "폴더 제거 실패: {message}" + }, + "statsLabel": "{folders}개 폴더 · {convos}개 대화", + "openFolder": "폴더 열기", + "searchPlaceholder": "대화 검색...", + "viewFlat": "평면 보기", + "viewGrouped": "폴더별 그룹", + "noMatchingConversations": "일치하는 대화가 없습니다", + "removeFolderConfirmTitle": "이 폴더를 워크스페이스에서 제거하시겠습니까?", + "removeFolderConfirmDescription": "워크스페이스에서 \"{name}\"을(를) 제거하시겠습니까? 관련 탭과 터미널이 닫힙니다.", + "folderHeaderMenu": { + "focus": "포커스", + "closeFolderTabs": "이 폴더의 모든 탭 닫기", + "removeFromWorkspace": "워크스페이스에서 제거" } }, "conversation": { @@ -960,7 +977,9 @@ "closeOthers": "다른 항목 닫기", "closeAll": "모두 닫기", "tileDisplay": "타일 표시", - "untileDisplay": "타일 해제" + "untileDisplay": "타일 해제", + "closeFolderTabs": "{folder}의 모든 탭 닫기", + "revealInSidebar": "사이드바에서 표시" }, "fileWorkspace": { "files": "파일", @@ -976,7 +995,8 @@ "close": "닫기", "closeOthers": "다른 항목 닫기", "closeAll": "모두 닫기", - "hideTerminal": "터미널 숨기기 ({shortcut})" + "hideTerminal": "터미널 숨기기 ({shortcut})", + "openFolderFirst": "먼저 폴더를 여세요" }, "sessionFiles": { "currentResponse": "현재 응답", @@ -1792,6 +1812,25 @@ "hunkLabel": "청크 {index}", "loadingHunk": "Hunk 로딩 중...", "noDiffData": "Diff 데이터 없음" + }, + "conversationContextBar": { + "searchFolder": "Search folder...", + "searchBranch": "Search branch...", + "noFolders": "No folders", + "noBranches": "No branches", + "noBranch": "(no branch)", + "openNewFolder": "Open folder from disk...", + "cancel": "Cancel", + "create": "Create", + "commit": "Commit", + "push": "Push", + "merge": "Merge", + "toasts": { + "folderChanged": "Switched to {name}", + "openFolderFailed": "Failed to open folder", + "openStashFailed": "Failed to open stash window", + "openMergeFailed": "Failed to open merge window" + } } }, "ProjectBoot": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 8706491..6013f31 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -825,7 +825,24 @@ "noNewSessionsFound": "Nenhuma nova sessão encontrada (ignoradas {skipped})", "importFailed": "Falha na importação: {message}", "reviewCompleted": "Marcadas {count, plural, one {# sessão em revisão} other {# sessões em revisão}} como concluídas", - "completeReviewFailed": "Falha ao concluir sessões em revisão: {message}" + "completeReviewFailed": "Falha ao concluir sessões em revisão: {message}", + "folderOpened": "Pasta {name} aberta", + "folderRemoved": "Pasta {name} removida", + "openFolderFailed": "Falha ao abrir pasta", + "removeFolderFailed": "Falha ao remover pasta: {message}" + }, + "statsLabel": "{folders} pastas · {convos} conversas", + "openFolder": "Abrir pasta", + "searchPlaceholder": "Buscar conversas...", + "viewFlat": "Visualização plana", + "viewGrouped": "Agrupar por pasta", + "noMatchingConversations": "Nenhuma conversa correspondente", + "removeFolderConfirmTitle": "Remover pasta do espaço de trabalho?", + "removeFolderConfirmDescription": "Remover \"{name}\" do espaço de trabalho? As abas e terminais relacionados serão fechados.", + "folderHeaderMenu": { + "focus": "Focar", + "closeFolderTabs": "Fechar todas as abas desta pasta", + "removeFromWorkspace": "Remover do espaço de trabalho" } }, "conversation": { @@ -960,7 +977,9 @@ "closeOthers": "Fechar outros", "closeAll": "Fechar tudo", "tileDisplay": "Exibição em mosaico", - "untileDisplay": "Sair do mosaico" + "untileDisplay": "Sair do mosaico", + "closeFolderTabs": "Fechar todas as abas de {folder}", + "revealInSidebar": "Mostrar na barra lateral" }, "fileWorkspace": { "files": "Arquivos", @@ -976,7 +995,8 @@ "close": "Fechar", "closeOthers": "Fechar outros", "closeAll": "Fechar tudo", - "hideTerminal": "Ocultar terminal ({shortcut})" + "hideTerminal": "Ocultar terminal ({shortcut})", + "openFolderFirst": "Abra primeiro uma pasta" }, "sessionFiles": { "currentResponse": "Resposta atual", @@ -1792,6 +1812,25 @@ "hunkLabel": "Bloco {index}", "loadingHunk": "Carregando hunk...", "noDiffData": "Sem dados de diff" + }, + "conversationContextBar": { + "searchFolder": "Search folder...", + "searchBranch": "Search branch...", + "noFolders": "No folders", + "noBranches": "No branches", + "noBranch": "(no branch)", + "openNewFolder": "Open folder from disk...", + "cancel": "Cancel", + "create": "Create", + "commit": "Commit", + "push": "Push", + "merge": "Merge", + "toasts": { + "folderChanged": "Switched to {name}", + "openFolderFailed": "Failed to open folder", + "openStashFailed": "Failed to open stash window", + "openMergeFailed": "Failed to open merge window" + } } }, "ProjectBoot": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index bbdbc40..55b4418 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -825,7 +825,24 @@ "noNewSessionsFound": "没有新会话(已跳过 {skipped} 个)", "importFailed": "导入失败:{message}", "reviewCompleted": "已将 {count} 个复查会话标记为已完成", - "completeReviewFailed": "批量完成复查会话失败:{message}" + "completeReviewFailed": "批量完成复查会话失败:{message}", + "folderOpened": "已打开文件夹 {name}", + "folderRemoved": "已移除文件夹 {name}", + "openFolderFailed": "打开文件夹失败", + "removeFolderFailed": "移除文件夹失败:{message}" + }, + "statsLabel": "{folders} 个文件夹 · {convos} 个会话", + "openFolder": "打开文件夹", + "searchPlaceholder": "搜索会话...", + "viewFlat": "平铺视图", + "viewGrouped": "按文件夹分组", + "noMatchingConversations": "未找到匹配的会话", + "removeFolderConfirmTitle": "从工作区移除该文件夹?", + "removeFolderConfirmDescription": "从工作区移除 \"{name}\"?其相关 Tab 与终端将会关闭。", + "folderHeaderMenu": { + "focus": "聚焦到此", + "closeFolderTabs": "关闭此文件夹的所有 Tab", + "removeFromWorkspace": "从工作区移除" } }, "conversation": { @@ -960,7 +977,9 @@ "closeOthers": "关闭其它", "closeAll": "关闭所有", "tileDisplay": "平铺显示", - "untileDisplay": "取消平铺" + "untileDisplay": "取消平铺", + "closeFolderTabs": "关闭 {folder} 的所有标签", + "revealInSidebar": "在侧边栏中定位" }, "fileWorkspace": { "files": "文件", @@ -976,7 +995,8 @@ "close": "关闭", "closeOthers": "关闭其它", "closeAll": "关闭所有", - "hideTerminal": "隐藏终端({shortcut})" + "hideTerminal": "隐藏终端({shortcut})", + "openFolderFirst": "请先打开一个文件夹" }, "sessionFiles": { "currentResponse": "当前响应", @@ -1792,6 +1812,25 @@ "hunkLabel": "代码块 {index}", "loadingHunk": "正在加载代码块...", "noDiffData": "无差异数据" + }, + "conversationContextBar": { + "searchFolder": "搜索文件夹...", + "searchBranch": "搜索分支...", + "noFolders": "暂无文件夹", + "noBranches": "暂无分支", + "noBranch": "(无分支)", + "openNewFolder": "从磁盘打开文件夹...", + "cancel": "取消", + "create": "创建", + "commit": "提交", + "push": "推送", + "merge": "合并", + "toasts": { + "folderChanged": "已切换到 {name}", + "openFolderFailed": "打开文件夹失败", + "openStashFailed": "打开贮藏窗口失败", + "openMergeFailed": "打开合并窗口失败" + } } }, "ProjectBoot": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 5756a63..96f692c 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -825,7 +825,24 @@ "noNewSessionsFound": "沒有新會話(已跳過 {skipped} 個)", "importFailed": "匯入失敗:{message}", "reviewCompleted": "已將 {count} 個複查會話標記為已完成", - "completeReviewFailed": "批次完成複查會話失敗:{message}" + "completeReviewFailed": "批次完成複查會話失敗:{message}", + "folderOpened": "已開啟資料夾 {name}", + "folderRemoved": "已移除資料夾 {name}", + "openFolderFailed": "開啟資料夾失敗", + "removeFolderFailed": "移除資料夾失敗:{message}" + }, + "statsLabel": "{folders} 個資料夾 · {convos} 個對話", + "openFolder": "開啟資料夾", + "searchPlaceholder": "搜尋對話...", + "viewFlat": "平鋪視圖", + "viewGrouped": "依資料夾分組", + "noMatchingConversations": "找不到符合的對話", + "removeFolderConfirmTitle": "從工作區移除此資料夾?", + "removeFolderConfirmDescription": "從工作區移除 \"{name}\"?相關分頁與終端機將會關閉。", + "folderHeaderMenu": { + "focus": "聚焦至此", + "closeFolderTabs": "關閉此資料夾的所有分頁", + "removeFromWorkspace": "從工作區移除" } }, "conversation": { @@ -960,7 +977,9 @@ "closeOthers": "關閉其它", "closeAll": "關閉所有", "tileDisplay": "平鋪顯示", - "untileDisplay": "取消平鋪" + "untileDisplay": "取消平鋪", + "closeFolderTabs": "關閉 {folder} 的所有標籤", + "revealInSidebar": "在側邊欄中定位" }, "fileWorkspace": { "files": "檔案", @@ -976,7 +995,8 @@ "close": "關閉", "closeOthers": "關閉其它", "closeAll": "關閉所有", - "hideTerminal": "隱藏終端({shortcut})" + "hideTerminal": "隱藏終端({shortcut})", + "openFolderFirst": "請先開啟一個資料夾" }, "sessionFiles": { "currentResponse": "目前回應", @@ -1792,6 +1812,25 @@ "hunkLabel": "區塊 {index}", "loadingHunk": "正在載入區塊...", "noDiffData": "無差異資料" + }, + "conversationContextBar": { + "searchFolder": "Search folder...", + "searchBranch": "Search branch...", + "noFolders": "No folders", + "noBranches": "No branches", + "noBranch": "(no branch)", + "openNewFolder": "Open folder from disk...", + "cancel": "Cancel", + "create": "Create", + "commit": "Commit", + "push": "Push", + "merge": "Merge", + "toasts": { + "folderChanged": "Switched to {name}", + "openFolderFailed": "Failed to open folder", + "openStashFailed": "Failed to open stash window", + "openMergeFailed": "Failed to open merge window" + } } }, "ProjectBoot": { diff --git a/src/lib/api.ts b/src/lib/api.ts index 8c025ea..3513ea7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -21,7 +21,7 @@ import type { FolderDetail, DbConversationSummary, ImportResult, - OpenedConversation, + OpenedTab, GitStatusEntry, GitBranchList, GitPullResult, @@ -598,22 +598,48 @@ export async function getFolder(folderId: number): Promise { return getTransport().call("get_folder", { folderId }) } -export async function listFolderConversations(params: { - folder_id: number +export async function listAllConversations(params?: { + folder_ids?: number[] | null agent_type?: AgentType | null search?: string | null sort_by?: string | null status?: string | null }): Promise { - return getTransport().call("list_folder_conversations", { - folderId: params.folder_id, - agentType: params.agent_type ?? null, - search: params.search ?? null, - sortBy: params.sort_by ?? null, - status: params.status ?? null, + return getTransport().call("list_all_conversations", { + folderIds: params?.folder_ids ?? null, + agentType: params?.agent_type ?? null, + search: params?.search ?? null, + sortBy: params?.sort_by ?? null, + status: params?.status ?? null, }) } +export async function listOpenedTabs(): Promise { + return getTransport().call("list_opened_tabs") +} + +export async function saveOpenedTabs(items: OpenedTab[]): Promise { + return getTransport().call("save_opened_tabs", { items }) +} + +export async function listOpenFolderDetails(): Promise { + return getTransport().call("list_open_folder_details") +} + +export async function listAllFolderDetails(): Promise { + return getTransport().call("list_all_folder_details") +} + +export async function openFolderById(folderId: number): Promise { + return getTransport().call("open_folder_by_id", { folderId }) +} + +export async function removeFolderFromWorkspace( + folderId: number +): Promise { + return getTransport().call("remove_folder_from_workspace", { folderId }) +} + export async function importLocalConversations( folderId: number ): Promise { @@ -626,16 +652,6 @@ export async function getFolderConversation( return getTransport().call("get_folder_conversation", { conversationId }) } -export async function saveFolderOpenedConversations( - folderId: number, - items: OpenedConversation[] -): Promise { - return getTransport().call("save_folder_opened_conversations", { - folderId, - items, - }) -} - export async function setFolderParentBranch( path: string, parentBranch: string | null @@ -1053,23 +1069,8 @@ export async function gitAddFiles( // Window management commands -export async function openFolderWindow( - path: string, - options?: { newWindow?: boolean } -): Promise { - if (getTransport().isDesktop()) { - return getTransport().call("open_folder_window", { path }) - } - const entry = await getTransport().call<{ id: number }>( - "open_folder_window", - { path } - ) - const url = `/folder?id=${entry.id}` - if (options?.newWindow) { - window.open(url, `folder-${entry.id}`) - } else { - window.location.href = url - } +export async function openFolder(path: string): Promise { + return getTransport().call("open_folder", { path }) } export async function openCommitWindow(folderId: number): Promise { @@ -1120,7 +1121,9 @@ export async function openProjectBootWindow(source?: string): Promise { if (getTransport().isDesktop()) { return getTransport().call("open_project_boot_window", { source }) } - window.open("/project-boot", "project-boot") + if (typeof window !== "undefined") { + window.open("/project-boot", "project-boot") + } } export async function detectPackageManager( @@ -1145,27 +1148,6 @@ export async function createShadcnProject(params: { }) } -export async function listOpenFolders(): Promise { - return getTransport().call("list_open_folders") -} - -export async function focusFolderWindow(folderId: number): Promise { - if (getTransport().isDesktop()) { - return getTransport().call("focus_folder_window", { folderId }) - } - // Web mode: open empty string to focus existing named window without reload. - // If the window doesn't exist (was closed), open the folder page. - const win = window.open("", `folder-${folderId}`) - if ( - !win || - win.closed || - !win.location.href || - win.location.href === "about:blank" - ) { - window.open(`/folder?id=${folderId}`, `folder-${folderId}`) - } -} - // Conversation CRUD commands export async function createConversation( diff --git a/src/lib/folder-badge.ts b/src/lib/folder-badge.ts new file mode 100644 index 0000000..d07fd40 --- /dev/null +++ b/src/lib/folder-badge.ts @@ -0,0 +1,33 @@ +/** + * Stable folder color + initial derivation for multi-folder visual identity + * across tab bar, terminal tab bar, and sidebar conversation cards. + */ + +const FOLDER_COLORS = [ + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-emerald-500", + "bg-teal-500", + "bg-cyan-500", + "bg-sky-500", + "bg-blue-500", + "bg-indigo-500", + "bg-violet-500", + "bg-purple-500", + "bg-fuchsia-500", + "bg-pink-500", +] as const + +export function folderBadgeColor(folderId: number): string { + return FOLDER_COLORS[Math.abs(folderId) % FOLDER_COLORS.length] +} + +export function folderBadgeLabel(name: string): string { + if (!name) return "?" + const match = name.match(/^(\p{L}|\p{N})/u) + return (match ? match[1] : name.slice(0, 1)).toUpperCase() +} diff --git a/src/lib/message-input-draft.ts b/src/lib/message-input-draft.ts index d604dd6..ed925f5 100644 --- a/src/lib/message-input-draft.ts +++ b/src/lib/message-input-draft.ts @@ -90,9 +90,9 @@ export function buildConversationDraftStorageKey( } export function buildNewConversationDraftStorageKey(params: { - folderId: number + tabId: string }): string { - return `new:${params.folderId}` + return `new:${params.tabId}` } export function loadMessageInputDraft(draftKey: string): string | null { diff --git a/src/lib/sidebar-view-mode-storage.ts b/src/lib/sidebar-view-mode-storage.ts new file mode 100644 index 0000000..db9cbb8 --- /dev/null +++ b/src/lib/sidebar-view-mode-storage.ts @@ -0,0 +1,55 @@ +"use client" + +export type SidebarViewMode = "flat" | "grouped" + +const VIEW_MODE_KEY = "workspace:sidebar-view-mode" +const FOLDER_EXPANDED_KEY = "workspace:sidebar-folder-expanded" + +export function loadSidebarViewMode(): SidebarViewMode { + if (typeof window === "undefined") return "flat" + try { + const raw = localStorage.getItem(VIEW_MODE_KEY) + if (raw === "flat" || raw === "grouped") return raw + } catch { + /* ignore */ + } + return "flat" +} + +export function saveSidebarViewMode(mode: SidebarViewMode): void { + if (typeof window === "undefined") return + try { + localStorage.setItem(VIEW_MODE_KEY, mode) + } catch { + /* ignore */ + } +} + +export function loadFolderExpanded(): Record { + if (typeof window === "undefined") return {} + try { + const raw = localStorage.getItem(FOLDER_EXPANDED_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== "object") return {} + const result: Record = {} + for (const [k, v] of Object.entries(parsed as Record)) { + const id = Number(k) + if (!Number.isNaN(id) && typeof v === "boolean") { + result[id] = v + } + } + return result + } catch { + return {} + } +} + +export function saveFolderExpanded(state: Record): void { + if (typeof window === "undefined") return + try { + localStorage.setItem(FOLDER_EXPANDED_KEY, JSON.stringify(state)) + } catch { + /* ignore */ + } +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 5b52422..ee9cddf 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -19,7 +19,7 @@ import type { FolderDetail, DbConversationSummary, ImportResult, - OpenedConversation, + OpenedTab, GitStatusEntry, GitBranchList, GitPullResult, @@ -470,22 +470,44 @@ export async function getFolder(folderId: number): Promise { return invoke("get_folder", { folderId }) } -export async function listFolderConversations(params: { - folder_id: number +export async function listAllConversations(params?: { + folder_ids?: number[] | null agent_type?: AgentType | null search?: string | null sort_by?: string | null status?: string | null }): Promise { - return invoke("list_folder_conversations", { - folderId: params.folder_id, - agentType: params.agent_type ?? null, - search: params.search ?? null, - sortBy: params.sort_by ?? null, - status: params.status ?? null, + return invoke("list_all_conversations", { + folderIds: params?.folder_ids ?? null, + agentType: params?.agent_type ?? null, + search: params?.search ?? null, + sortBy: params?.sort_by ?? null, + status: params?.status ?? null, }) } +export async function listOpenedTabs(): Promise { + return invoke("list_opened_tabs") +} + +export async function saveOpenedTabs(items: OpenedTab[]): Promise { + return invoke("save_opened_tabs", { items }) +} + +export async function listOpenFolderDetails(): Promise { + return invoke("list_open_folder_details") +} + +export async function openFolderById(folderId: number): Promise { + return invoke("open_folder_by_id", { folderId }) +} + +export async function removeFolderFromWorkspace( + folderId: number +): Promise { + return invoke("remove_folder_from_workspace", { folderId }) +} + export async function importLocalConversations( folderId: number ): Promise { @@ -498,13 +520,6 @@ export async function getFolderConversation( return invoke("get_folder_conversation", { conversationId }) } -export async function saveFolderOpenedConversations( - folderId: number, - items: OpenedConversation[] -): Promise { - return invoke("save_folder_opened_conversations", { folderId, items }) -} - export async function setFolderParentBranch( path: string, parentBranch: string | null @@ -858,8 +873,8 @@ export async function gitAddFiles( // Window management commands -export async function openFolderWindow(path: string): Promise { - return invoke("open_folder_window", { path }) +export async function openFolder(path: string): Promise { + return invoke("open_folder", { path }) } export async function openCommitWindow(folderId: number): Promise { @@ -888,14 +903,6 @@ export async function openSettingsWindow( }) } -export async function listOpenFolders(): Promise { - return invoke("list_open_folders") -} - -export async function focusFolderWindow(folderId: number): Promise { - return invoke("focus_folder_window", { folderId }) -} - // Conversation CRUD commands export async function createConversation( diff --git a/src/lib/types.ts b/src/lib/types.ts index c127533..6680a06 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -163,11 +163,12 @@ export interface FolderDetail { parent_branch: string | null default_agent_type: AgentType | null last_opened_at: string - opened_conversations: OpenedConversation[] } -export interface OpenedConversation { - conversation_id: number +export interface OpenedTab { + id: number + folder_id: number + conversation_id: number | null agent_type: AgentType position: number is_active: boolean