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

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

- Persist opened tabs to the database (opened_tabs entity + migration)
  so tab layout survives restarts and deep-link bootstrap restores state
- Replace FolderContext shim with AppWorkspaceProvider, ActiveFolderProvider,
  and TabProvider; expose both opened (folders) and full DB (allFolders)
  listings via list_all_folder_details
- Return conversations across all non-deleted folders from list_all when
  no folder filter is given, so the sidebar can show every folder's history
- Add ConversationContextBar above the chat input with folder picker
  (auto-opens unopened folders on select), branch picker, and commit /
  push / merge / stash entries to restore BranchDropdown functionality
- Rework sidebar with stats header, search, flat / folder-grouped view
  modes (localStorage-persisted), reveal-in-sidebar event subscriber,
  and per-folder context menu (focus, close tabs, remove from workspace);
  indent conversations under folder headers in grouped mode
- Gate terminal creation on active folder and show folder context
- Remove deprecated BranchDropdown, FolderNameDropdown, welcome route,
  and per-folder window commands
- Localize all new strings across 10 locales
This commit is contained in:
xintaofei
2026-04-20 21:22:36 +08:00
parent 10801bf393
commit d9323d7399
89 changed files with 3701 additions and 2743 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "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": [ "permissions": [
"core:default", "core:default",
"core:window:default", "core:window:default",
+1 -2
View File
@@ -6,8 +6,7 @@
"linux" "linux"
], ],
"windows": [ "windows": [
"welcome", "main",
"folder-*",
"commit-*", "commit-*",
"merge-*", "merge-*",
"stash-*", "stash-*",
+26 -3
View File
@@ -19,15 +19,38 @@ use crate::parsers::{path_eq_for_matching, AgentParser, ParseError};
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn list_folder_conversations( pub async fn list_all_conversations(
db: tauri::State<'_, AppDatabase>, db: tauri::State<'_, AppDatabase>,
folder_id: i32, folder_ids: Option<Vec<i32>>,
agent_type: Option<AgentType>, agent_type: Option<AgentType>,
search: Option<String>, search: Option<String>,
sort_by: Option<String>, sort_by: Option<String>,
status: Option<String>, status: Option<String>,
) -> Result<Vec<DbConversationSummary>, AppCommandError> { ) -> Result<Vec<DbConversationSummary>, 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<Vec<OpenedTab>, 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<OpenedTab>,
) -> Result<(), AppCommandError> {
use crate::db::service::tab_service;
tab_service::save_all_tabs(&db.conn, items)
.await .await
.map_err(AppCommandError::from) .map_err(AppCommandError::from)
} }
+45 -5
View File
@@ -23,7 +23,7 @@ use crate::db::service::folder_service;
use crate::db::AppDatabase; use crate::db::AppDatabase;
use crate::models::GitCredentials; use crate::models::GitCredentials;
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
use crate::models::{FolderDetail, FolderHistoryEntry, OpenedConversation}; use crate::models::{FolderDetail, FolderHistoryEntry};
use crate::web::event_bridge::EventEmitter; use crate::web::event_bridge::EventEmitter;
/// Configure a git command for remote operations: /// Configure a git command for remote operations:
@@ -526,12 +526,52 @@ pub async fn remove_folder_from_history(
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[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<Vec<FolderDetail>, 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<Vec<FolderDetail>, 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>, db: tauri::State<'_, AppDatabase>,
folder_id: i32, folder_id: i32,
items: Vec<OpenedConversation>, ) -> Result<FolderDetail, AppCommandError> {
) -> Result<(), DbError> { folder_service::set_folder_open(&db.conn, folder_id, true)
folder_service::save_opened_conversations(&db.conn, folder_id, items).await .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)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
+17 -125
View File
@@ -10,7 +10,7 @@ use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use crate::app_error::AppCommandError; use crate::app_error::AppCommandError;
use crate::db::AppDatabase; use crate::db::AppDatabase;
use crate::db::service::app_metadata_service; use crate::db::service::app_metadata_service;
use crate::models::FolderHistoryEntry; use crate::models::FolderDetail;
/// Base traffic-light position (logical px) at 100 % zoom. /// Base traffic-light position (logical px) at 100 % zoom.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -85,10 +85,6 @@ pub struct CommitWindowState {
owner_by_commit_label: Mutex<HashMap<String, String>>, owner_by_commit_label: Mutex<HashMap<String, String>>,
} }
pub fn folder_window_label(folder_id: i32) -> String {
format!("folder-{folder_id}")
}
/// Detect macOS system dark mode via `defaults read`. /// Detect macOS system dark mode via `defaults read`.
/// Result is cached for the process lifetime via `OnceLock`. /// Result is cached for the process lifetime via `OnceLock`.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -288,13 +284,6 @@ impl CommitWindowState {
} }
} }
fn get_folder_id_from_window(window: &tauri::WebviewWindow) -> Option<i32> {
let url = window.url().ok()?;
url.query_pairs()
.find(|(key, _)| key == "id")
.and_then(|(_, value)| value.parse::<i32>().ok())
}
fn resolve_settings_route(section: Option<&str>) -> &'static str { fn resolve_settings_route(section: Option<&str>) -> &'static str {
match section { match section {
Some("appearance") => "settings/appearance", Some("appearance") => "settings/appearance",
@@ -331,103 +320,36 @@ fn resolve_settings_target(section: Option<&str>, agent_type: Option<&str>) -> S
route.to_string() 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<Vec<FolderHistoryEntry>, AppCommandError> {
let windows = app.webview_windows();
let mut folder_ids: Vec<i32> = 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<FolderHistoryEntry> = 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(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn open_folder_window( pub async fn open_folder_window(
app: AppHandle, app: AppHandle,
db: tauri::State<'_, AppDatabase>, db: tauri::State<'_, AppDatabase>,
path: String, path: String,
) -> Result<(), AppCommandError> { ) -> Result<FolderDetail, AppCommandError> {
// Add to history via DB // 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) let entry = crate::db::service::folder_service::add_folder(&db.conn, &path)
.await .await
.map_err(AppCommandError::from)?; .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") { if let Some(w) = app.get_webview_window("project-boot") {
let _ = w.close(); let _ = w.close();
} }
return 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();
} }
let url = WebviewUrl::App(format!("folder?id={}", entry.id).into()); Ok(folder)
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(())
} }
#[cfg(feature = "tauri-runtime")] #[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(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn open_stash_window( pub async fn open_stash_window(
@@ -818,18 +722,13 @@ pub async fn open_project_boot_window(
app: AppHandle, app: AppHandle,
source: Option<String>, source: Option<String>,
) -> Result<(), AppCommandError> { ) -> Result<(), AppCommandError> {
let _ = source;
if let Some(existing) = app.get_webview_window("project-boot") { if let Some(existing) = app.get_webview_window("project-boot") {
post_window_setup(&existing); post_window_setup(&existing);
let _ = existing.unminimize(); let _ = existing.unminimize();
existing.set_focus().map_err(|e| { existing.set_focus().map_err(|e| {
AppCommandError::window("Failed to focus project boot window", e.to_string()) 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(()); return Ok(());
} }
@@ -846,13 +745,6 @@ pub async fn open_project_boot_window(
})?; })?;
post_window_setup(&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(()) Ok(())
} }
+4 -4
View File
@@ -23,8 +23,8 @@ pub enum Relation {
#[sea_orm(has_many = "super::conversation::Entity")] #[sea_orm(has_many = "super::conversation::Entity")]
Conversations, Conversations,
#[sea_orm(has_many = "super::folder_opened_conversation::Entity")] #[sea_orm(has_many = "super::opened_tab::Entity")]
OpenedConversations, OpenedTabs,
#[sea_orm(has_many = "super::folder_command::Entity")] #[sea_orm(has_many = "super::folder_command::Entity")]
FolderCommands, FolderCommands,
@@ -36,9 +36,9 @@ impl Related<super::conversation::Entity> for Entity {
} }
} }
impl Related<super::folder_opened_conversation::Entity> for Entity { impl Related<super::opened_tab::Entity> for Entity {
fn to() -> RelationDef { fn to() -> RelationDef {
Relation::OpenedConversations.def() Relation::OpenedTabs.def()
} }
} }
+1 -1
View File
@@ -6,6 +6,6 @@ pub mod chat_channel_sender_context;
pub mod conversation; pub mod conversation;
pub mod folder; pub mod folder;
pub mod folder_command; pub mod folder_command;
pub mod folder_opened_conversation;
pub mod model_provider; pub mod model_provider;
pub mod opened_tab;
pub mod prelude; pub mod prelude;
@@ -1,16 +1,16 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "folder_opened_conversation")] #[sea_orm(table_name = "opened_tab")]
pub struct Model { pub struct Model {
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub folder_id: i32, pub folder_id: i32,
pub conversation_id: i32, pub conversation_id: Option<i32>,
pub agent_type: String,
pub position: i32, pub position: i32,
pub is_active: bool, pub is_active: bool,
pub is_pinned: bool, pub is_pinned: bool,
pub agent_type: String,
pub created_at: DateTimeUtc, pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc, pub updated_at: DateTimeUtc,
} }
+1 -1
View File
@@ -8,5 +8,5 @@ pub use super::chat_channel_sender_context::Entity as ChatChannelSenderContext;
pub use super::conversation::Entity as Conversation; pub use super::conversation::Entity as Conversation;
pub use super::folder::Entity as Folder; pub use super::folder::Entity as Folder;
pub use super::folder_command::Entity as FolderCommand; 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::model_provider::Entity as ModelProvider;
pub use super::opened_tab::Entity as OpenedTab;
@@ -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,
}
+2
View File
@@ -9,6 +9,7 @@ mod m20260330_000001_chat_channel;
mod m20260401_000001_chat_channel_sender_context; mod m20260401_000001_chat_channel_sender_context;
mod m20260404_000001_model_provider; mod m20260404_000001_model_provider;
mod m20260406_000001_agent_setting_model_provider; mod m20260406_000001_agent_setting_model_provider;
mod m20260420_000001_opened_tabs;
pub struct Migrator; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -24,6 +25,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260401_000001_chat_channel_sender_context::Migration), Box::new(m20260401_000001_chat_channel_sender_context::Migration),
Box::new(m20260404_000001_model_provider::Migration), Box::new(m20260404_000001_model_provider::Migration),
Box::new(m20260406_000001_agent_setting_model_provider::Migration), Box::new(m20260406_000001_agent_setting_model_provider::Migration),
Box::new(m20260420_000001_opened_tabs::Migration),
] ]
} }
} }
@@ -4,7 +4,7 @@ use sea_orm::{
QueryFilter, QueryOrder, Set, QueryFilter, QueryOrder, Set,
}; };
use crate::db::entities::conversation; use crate::db::entities::{conversation, folder};
use crate::db::error::DbError; use crate::db::error::DbError;
use crate::models::{AgentType, DbConversationSummary}; use crate::models::{AgentType, DbConversationSummary};
@@ -195,3 +195,68 @@ pub async fn list_by_folder(
Ok(summaries) 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<Vec<i32>>,
agent_type: Option<AgentType>,
search: Option<String>,
sort_by: Option<String>,
status: Option<String>,
) -> Result<Vec<DbConversationSummary>, DbError> {
let mut query = conversation::Entity::find()
.filter(conversation::Column::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<i32> = 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::<conversation::ConversationStatus>(
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())
}
+31 -87
View File
@@ -1,14 +1,14 @@
use chrono::Utc; use chrono::Utc;
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter,
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement, QueryOrder, Set,
}; };
use crate::db::entities::{folder, folder_opened_conversation}; use crate::db::entities::folder;
use crate::db::error::DbError; use crate::db::error::DbError;
use crate::models::agent::AgentType; use crate::models::agent::AgentType;
use crate::models::{FolderDetail, FolderHistoryEntry, OpenedConversation}; use crate::models::{FolderDetail, FolderHistoryEntry};
fn to_entry(m: folder::Model) -> FolderHistoryEntry { fn to_entry(m: folder::Model) -> FolderHistoryEntry {
FolderHistoryEntry { FolderHistoryEntry {
@@ -24,7 +24,7 @@ fn parse_agent_type(s: &Option<String>) -> Option<AgentType> {
.and_then(|v| serde_json::from_value(serde_json::Value::String(v.to_string())).ok()) .and_then(|v| serde_json::from_value(serde_json::Value::String(v.to_string())).ok())
} }
fn to_detail(m: folder::Model, opened: Vec<OpenedConversation>) -> FolderDetail { fn to_detail(m: folder::Model) -> FolderDetail {
let default_agent_type = parse_agent_type(&m.default_agent_type); let default_agent_type = parse_agent_type(&m.default_agent_type);
FolderDetail { FolderDetail {
id: m.id, id: m.id,
@@ -34,7 +34,6 @@ fn to_detail(m: folder::Model, opened: Vec<OpenedConversation>) -> FolderDetail
parent_branch: m.parent_branch, parent_branch: m.parent_branch,
default_agent_type, default_agent_type,
last_opened_at: m.last_opened_at, last_opened_at: m.last_opened_at,
opened_conversations: opened,
} }
} }
@@ -47,13 +46,7 @@ pub async fn get_folder_by_id(
.one(conn) .one(conn)
.await?; .await?;
match row { Ok(row.map(to_detail))
None => Ok(None),
Some(folder_model) => {
let opened = load_opened_conversations(conn, folder_model.id).await?;
Ok(Some(to_detail(folder_model, opened)))
}
}
} }
pub async fn add_folder( pub async fn add_folder(
@@ -126,80 +119,6 @@ pub async fn remove_folder(conn: &DatabaseConnection, path: &str) -> Result<(),
Ok(()) Ok(())
} }
pub async fn save_opened_conversations(
conn: &DatabaseConnection,
folder_id: i32,
items: Vec<OpenedConversation>,
) -> Result<(), DbError> {
// Delete all existing opened conversations for this folder
folder_opened_conversation::Entity::delete_many()
.filter(folder_opened_conversation::Column::FolderId.eq(folder_id))
.exec(conn)
.await?;
if items.is_empty() {
return Ok(());
}
// Batch insert with raw SQL for efficiency
let now = Utc::now();
let now_str = now.format("%Y-%m-%d %H:%M:%S %:z").to_string();
let mut values = Vec::with_capacity(items.len());
for item in &items {
let agent_str = serde_json::to_value(item.agent_type)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default();
values.push(format!(
"({}, {}, {}, {}, {}, '{}', '{}', '{}')",
folder_id,
item.conversation_id,
item.position,
item.is_active as i32,
item.is_pinned as i32,
agent_str,
now_str,
now_str,
));
}
let sql = format!(
"INSERT INTO folder_opened_conversation (folder_id, conversation_id, position, is_active, is_pinned, agent_type, created_at, updated_at) VALUES {}",
values.join(", ")
);
conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
.await?;
Ok(())
}
async fn load_opened_conversations(
conn: &DatabaseConnection,
folder_id: i32,
) -> Result<Vec<OpenedConversation>, DbError> {
let rows = folder_opened_conversation::Entity::find()
.filter(folder_opened_conversation::Column::FolderId.eq(folder_id))
.order_by_asc(folder_opened_conversation::Column::Position)
.all(conn)
.await?;
Ok(rows
.into_iter()
.filter_map(|r| {
let agent_type = parse_agent_type(&Some(r.agent_type))?;
Some(OpenedConversation {
conversation_id: r.conversation_id,
agent_type,
position: r.position,
is_active: r.is_active,
is_pinned: r.is_pinned,
})
})
.collect())
}
pub async fn set_folder_parent_branch( pub async fn set_folder_parent_branch(
conn: &DatabaseConnection, conn: &DatabaseConnection,
folder_id: i32, folder_id: i32,
@@ -244,3 +163,28 @@ pub async fn list_open_folders(
Ok(rows.into_iter().map(to_entry).collect()) Ok(rows.into_iter().map(to_entry).collect())
} }
pub async fn list_open_folder_details(
conn: &DatabaseConnection,
) -> Result<Vec<FolderDetail>, DbError> {
let rows = folder::Entity::find()
.filter(folder::Column::DeletedAt.is_null())
.filter(folder::Column::IsOpen.eq(true))
.order_by_desc(folder::Column::LastOpenedAt)
.all(conn)
.await?;
Ok(rows.into_iter().map(to_detail).collect())
}
pub async fn list_all_folder_details(
conn: &DatabaseConnection,
) -> Result<Vec<FolderDetail>, DbError> {
let rows = folder::Entity::find()
.filter(folder::Column::DeletedAt.is_null())
.order_by_desc(folder::Column::LastOpenedAt)
.all(conn)
.await?;
Ok(rows.into_iter().map(to_detail).collect())
}
+1
View File
@@ -8,3 +8,4 @@ pub mod folder_service;
pub mod import_service; pub mod import_service;
pub mod model_provider_service; pub mod model_provider_service;
pub mod sender_context_service; pub mod sender_context_service;
pub mod tab_service;
+97
View File
@@ -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<AgentType> {
serde_json::from_value(serde_json::Value::String(s.to_string())).ok()
}
pub async fn list_all_tabs(conn: &DatabaseConnection) -> Result<Vec<OpenedTab>, 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<OpenedTab>,
) -> 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(())
}
+20 -74
View File
@@ -34,14 +34,6 @@ mod tauri_app {
static APP_QUITTING: AtomicBool = AtomicBool::new(false); static APP_QUITTING: AtomicBool = AtomicBool::new(false);
fn get_folder_id_from_url(window: &tauri::Window) -> Option<i32> {
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::<i32>().ok())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
if let Err(err) = fix_path_env::fix() { if let Err(err) = fix_path_env::fix() {
@@ -128,28 +120,20 @@ mod tauri_app {
}); });
} }
// Restore previously open folders or show welcome // Single-window workspace: ensure the main window exists.
let db = app.state::<db::AppDatabase>(); // Workspace state (open folders, opened tabs, active tab) is
let open_folders = tauri::async_runtime::block_on( // restored by the frontend via `list_open_folder_details` /
db::service::folder_service::list_open_folders(&db.conn), // `list_opened_tabs` inside the main window.
) if app.get_webview_window("main").is_none() {
.unwrap_or_default(); let url = tauri::WebviewUrl::App("workspace".into());
let builder = tauri::WebviewWindowBuilder::new(app, "main", url)
if open_folders.is_empty() { .title("Codeg")
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) .inner_size(1260.0, 860.0)
.min_inner_size(900.0, 600.0); .min_inner_size(900.0, 600.0);
if let Ok(w) = windows::apply_platform_window_style(builder).build() { if let Ok(w) = windows::apply_platform_window_style(builder).build() {
windows::post_window_setup(&w); windows::post_window_setup(&w);
} }
} }
}
Ok(()) Ok(())
}) })
@@ -197,70 +181,31 @@ mod tauri_app {
}); });
} }
if label == "project-boot" if label == "main"
&& matches!( && matches!(event, tauri::WindowEvent::CloseRequested { .. })
event,
tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed
)
{ {
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 tauri::WindowEvent::CloseRequested { .. } = event {
if label.starts_with("folder-") {
let app = window.app_handle(); let app = window.app_handle();
if let Some(cm) = app.try_state::<ConnectionManager>() { if let Some(cm) = app.try_state::<ConnectionManager>() {
let disconnected = tauri::async_runtime::block_on( let disconnected = tauri::async_runtime::block_on(
cm.disconnect_by_owner_window(&label), cm.disconnect_by_owner_window(&label),
); );
eprintln!( eprintln!(
"[ACP] folder window closing label={} disconnected_connections={}", "[ACP] main window closing disconnected_connections={}",
label, disconnected 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::<db::AppDatabase>() {
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::<TerminalManager>() { if let Some(tm) = app.try_state::<TerminalManager>() {
let killed = tm.kill_by_owner_window(&label); let killed = tm.kill_by_owner_window(&label);
eprintln!( eprintln!("[TERM] main window closing killed_terminals={}", killed);
"[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);
}
} }
} }
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
conversations::list_conversations, conversations::list_conversations,
conversations::get_conversation, conversations::get_conversation,
conversations::list_folder_conversations, conversations::list_all_conversations,
conversations::list_opened_tabs,
conversations::save_opened_tabs,
conversations::import_local_conversations, conversations::import_local_conversations,
conversations::get_folder_conversation, conversations::get_folder_conversation,
conversations::list_folders, conversations::list_folders,
@@ -273,6 +218,10 @@ mod tauri_app {
conversations::delete_conversation, conversations::delete_conversation,
folders::load_folder_history, folders::load_folder_history,
folders::get_folder, 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::add_folder_to_history,
folders::set_folder_parent_branch, folders::set_folder_parent_branch,
folders::remove_folder_from_history, folders::remove_folder_from_history,
@@ -322,7 +271,6 @@ mod tauri_app {
folders::git_resolve_conflict, folders::git_resolve_conflict,
folders::git_abort_operation, folders::git_abort_operation,
folders::git_continue_operation, folders::git_continue_operation,
folders::save_folder_opened_conversations,
workspace_state_commands::start_workspace_state_stream, workspace_state_commands::start_workspace_state_stream,
workspace_state_commands::stop_workspace_state_stream, workspace_state_commands::stop_workspace_state_stream,
workspace_state_commands::get_workspace_snapshot, workspace_state_commands::get_workspace_snapshot,
@@ -342,8 +290,6 @@ mod tauri_app {
windows::open_folder_window, windows::open_folder_window,
windows::open_commit_window, windows::open_commit_window,
windows::open_settings_window, windows::open_settings_window,
windows::list_open_folders,
windows::focus_folder_window,
windows::open_merge_window, windows::open_merge_window,
windows::open_stash_window, windows::open_stash_window,
windows::open_push_window, windows::open_push_window,
+4 -3
View File
@@ -20,12 +20,13 @@ pub struct FolderDetail {
pub parent_branch: Option<String>, pub parent_branch: Option<String>,
pub default_agent_type: Option<AgentType>, pub default_agent_type: Option<AgentType>,
pub last_opened_at: DateTime<Utc>, pub last_opened_at: DateTime<Utc>,
pub opened_conversations: Vec<OpenedConversation>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenedConversation { pub struct OpenedTab {
pub conversation_id: i32, pub id: i32,
pub folder_id: i32,
pub conversation_id: Option<i32>,
pub agent_type: AgentType, pub agent_type: AgentType,
pub position: i32, pub position: i32,
pub is_active: bool, pub is_active: bool,
+1 -1
View File
@@ -14,7 +14,7 @@ pub use conversation::{
DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats, DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats,
SidebarData, SidebarData,
}; };
pub use folder::{FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedConversation}; pub use folder::{FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedTab};
pub use message::{ pub use message::{
AgentExecutionStats, AgentToolCall, ContentBlock, MessageRole, MessageTurn, TurnRole, AgentExecutionStats, AgentToolCall, ContentBlock, MessageRole, MessageTurn, TurnRole,
TurnUsage, UnifiedMessage, TurnUsage, UnifiedMessage,
+36 -7
View File
@@ -9,24 +9,24 @@ use crate::commands::conversations as conv_commands;
use crate::db::service::{conversation_service, folder_service, import_service}; use crate::db::service::{conversation_service, folder_service, import_service};
use crate::models::*; use crate::models::*;
#[derive(Deserialize)] #[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ListFolderConversationsParams { pub struct ListAllConversationsParams {
pub folder_id: i32, pub folder_ids: Option<Vec<i32>>,
pub agent_type: Option<AgentType>, pub agent_type: Option<AgentType>,
pub search: Option<String>, pub search: Option<String>,
pub sort_by: Option<String>, pub sort_by: Option<String>,
pub status: Option<String>, pub status: Option<String>,
} }
pub async fn list_folder_conversations( pub async fn list_all_conversations(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
Json(params): Json<ListFolderConversationsParams>, Json(params): Json<ListAllConversationsParams>,
) -> Result<Json<Vec<DbConversationSummary>>, AppCommandError> { ) -> Result<Json<Vec<DbConversationSummary>>, AppCommandError> {
let db = &state.db; let db = &state.db;
let result = conversation_service::list_by_folder( let result = conversation_service::list_all(
&db.conn, &db.conn,
params.folder_id, params.folder_ids,
params.agent_type, params.agent_type,
params.search, params.search,
params.sort_by, params.sort_by,
@@ -37,6 +37,35 @@ pub async fn list_folder_conversations(
Ok(Json(result)) Ok(Json(result))
} }
pub async fn list_opened_tabs(
Extension(state): Extension<Arc<AppState>>,
) -> Result<Json<Vec<OpenedTab>>, 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<OpenedTab>,
}
pub async fn save_opened_tabs(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<SaveOpenedTabsParams>,
) -> Result<Json<()>, 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)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ListConversationsParams { pub struct ListConversationsParams {
+51 -25
View File
@@ -53,45 +53,71 @@ pub struct AddFolderParams {
pub path: String, pub path: String,
} }
/// Web equivalent of `open_folder_window`: adds the folder to DB and returns its ID. /// Add the folder to the workspace (upsert + set is_open=true) and return its full detail.
/// The web client then navigates to `/folder?id=N` itself. /// Previously this spawned a new window; the new single-window workspace model
pub async fn open_folder_window( /// simply returns the folder info so the client can update its local state.
pub async fn open_folder(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
Json(params): Json<AddFolderParams>, Json(params): Json<AddFolderParams>,
) -> Result<Json<FolderHistoryEntry>, AppCommandError> { ) -> Result<Json<FolderDetail>, AppCommandError> {
let db = &state.db; let db = &state.db;
let entry = folder_service::add_folder(&db.conn, &params.path) let entry = folder_service::add_folder(&db.conn, &params.path)
.await .await
.map_err(AppCommandError::from)?; .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<Arc<AppState>>,
) -> Result<Json<Vec<FolderDetail>>, 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<Arc<AppState>>,
) -> Result<Json<Vec<FolderDetail>>, 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<Arc<AppState>>,
Json(params): Json<FolderIdParams>,
) -> Result<Json<FolderDetail>, 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<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
Json(params): Json<FolderIdParams>, Json(params): Json<FolderIdParams>,
) -> Result<Json<()>, AppCommandError> { ) -> Result<Json<()>, AppCommandError> {
use crate::db::service::tab_service;
let db = &state.db; 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 .await
.map_err(AppCommandError::from)?; .map_err(AppCommandError::from)?;
Ok(Json(())) folder_service::set_folder_open(&db.conn, params.folder_id, false)
}
// --- New handlers below ---
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveFolderOpenedConversationsParams {
pub folder_id: i32,
pub items: Vec<OpenedConversation>,
}
pub async fn save_folder_opened_conversations(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<SaveFolderOpenedConversationsParams>,
) -> Result<Json<()>, AppCommandError> {
let db = &state.db;
folder_service::save_opened_conversations(&db.conn, params.folder_id, params.items)
.await .await
.map_err(AppCommandError::from)?; .map_err(AppCommandError::from)?;
Ok(Json(())) Ok(Json(()))
+23 -10
View File
@@ -34,13 +34,21 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
post(handlers::conversations::get_conversation), post(handlers::conversations::get_conversation),
) )
.route( .route(
"/list_folder_conversations", "/list_all_conversations",
post(handlers::conversations::list_folder_conversations), post(handlers::conversations::list_all_conversations),
) )
.route( .route(
"/get_folder_conversation", "/get_folder_conversation",
post(handlers::conversations::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( .route(
"/import_local_conversations", "/import_local_conversations",
post(handlers::conversations::import_local_conversations), post(handlers::conversations::import_local_conversations),
@@ -81,13 +89,22 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
post(handlers::folders::list_open_folders), post(handlers::folders::list_open_folders),
) )
.route( .route(
"/close_folder_window", "/list_open_folder_details",
post(handlers::folders::close_folder_window), 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("/get_folder", post(handlers::folders::get_folder))
.route("/open_folder", post(handlers::folders::open_folder))
.route( .route(
"/open_folder_window", "/open_folder_by_id",
post(handlers::folders::open_folder_window), post(handlers::folders::open_folder_by_id),
)
.route(
"/remove_folder_from_workspace",
post(handlers::folders::remove_folder_from_workspace),
) )
.route( .route(
"/add_folder_to_history", "/add_folder_to_history",
@@ -105,10 +122,6 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
"/create_folder_directory", "/create_folder_directory",
post(handlers::folders::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_git_branch", post(handlers::folders::get_git_branch))
.route( .route(
"/get_home_directory", "/get_home_directory",
-7
View File
@@ -1,7 +0,0 @@
"use client"
import { ConversationDetailPanel } from "@/components/conversations/conversation-detail-panel"
export default function FolderPage() {
return <ConversationDetailPanel />
}
+2 -2
View File
@@ -16,7 +16,7 @@ export default function LoginPage() {
// Desktop users skip login entirely // Desktop users skip login entirely
if (isDesktop()) { if (isDesktop()) {
router.replace("/welcome") router.replace("/workspace")
return null return null
} }
@@ -38,7 +38,7 @@ export default function LoginPage() {
if (res.ok) { if (res.ok) {
localStorage.setItem("codeg_token", token) localStorage.setItem("codeg_token", token)
router.replace("/welcome") router.replace("/workspace")
} else if (res.status === 401) { } else if (res.status === 401) {
setError("Token 无效,请检查后重试") setError("Token 无效,请检查后重试")
} else { } else {
+2 -2
View File
@@ -8,7 +8,7 @@ export default function Page() {
const router = useRouter() const router = useRouter()
useEffect(() => { useEffect(() => {
if (isDesktop()) { if (isDesktop()) {
router.replace("/welcome") router.replace("/workspace")
return return
} }
// Web mode: validate token before entering app // Web mode: validate token before entering app
@@ -28,7 +28,7 @@ export default function Page() {
}) })
.then((res) => { .then((res) => {
if (res.ok) { if (res.ok) {
router.replace("/welcome") router.replace("/workspace")
} else { } else {
localStorage.removeItem("codeg_token") localStorage.removeItem("codeg_token")
router.replace("/login") router.replace("/login")
-8
View File
@@ -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 <GitCredentialProvider>{children}</GitCredentialProvider>
}
-15
View File
@@ -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 <WelcomeScreen />
}
@@ -8,12 +8,15 @@ import {
useRef, useRef,
useState, useState,
} from "react" } from "react"
import { useSearchParams } from "next/navigation"
import type { ImperativePanelGroupHandle } from "react-resizable-panels" import type { ImperativePanelGroupHandle } from "react-resizable-panels"
import { FolderTitleBar } from "@/components/layout/folder-title-bar" import { FolderTitleBar } from "@/components/layout/folder-title-bar"
import { Sidebar } from "@/components/layout/sidebar" import { Sidebar } from "@/components/layout/sidebar"
import { StatusBar } from "@/components/layout/status-bar" 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 { TaskProvider } from "@/contexts/task-context"
import { AlertProvider } from "@/contexts/alert-context" import { AlertProvider } from "@/contexts/alert-context"
import { import {
@@ -43,23 +46,22 @@ import { AuxPanel } from "@/components/layout/aux-panel"
import { FileWorkspaceTabBar } from "@/components/files/file-workspace-tab-bar" import { FileWorkspaceTabBar } from "@/components/files/file-workspace-tab-bar"
import { FileWorkspacePanel } from "@/components/files/file-workspace-panel" import { FileWorkspacePanel } from "@/components/files/file-workspace-panel"
import { AppToaster } from "@/components/ui/app-toaster" import { AppToaster } from "@/components/ui/app-toaster"
import { DeepLinkBootstrap } from "@/components/workspace/deep-link-bootstrap"
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "@/components/ui/resizable" } from "@/components/ui/resizable"
import type { AgentType } from "@/lib/types"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useFolderContext } from "@/contexts/folder-context"
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
function FolderDocumentTitle() { function WorkspaceDocumentTitle() {
const { folder } = useFolderContext() const { activeFolder } = useActiveFolder()
useEffect(() => { useEffect(() => {
document.title = folder ? `${folder.name} - codeg` : "codeg" document.title = activeFolder ? `${activeFolder.name} - codeg` : "codeg"
}, [folder]) }, [activeFolder])
return null return null
} }
@@ -80,7 +82,6 @@ const MIN_CENTER_WIDTH_PX = 420
const MIN_WORKSPACE_HEIGHT_PX = 220 const MIN_WORKSPACE_HEIGHT_PX = 220
const LAYOUT_EPSILON = 0.25 const LAYOUT_EPSILON = 0.25
/** Syncs open tab keys from TabProvider to AcpConnectionsProvider */
function TabKeysSync() { function TabKeysSync() {
const { tabs } = useTabContext() const { tabs } = useTabContext()
const { registerOpenTabKeys } = useAcpActions() const { registerOpenTabKeys } = useAcpActions()
@@ -149,8 +150,7 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) {
panelGroup.setLayout(layout) panelGroup.setLayout(layout)
appliedLayoutRef.current = layout appliedLayoutRef.current = layout
} catch { } catch {
// The group can be transiently unavailable while registering panels. /* retry via onLayout */
// onLayout will retry once registration completes.
} }
}, []) }, [])
@@ -158,9 +158,6 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) {
if (mode === "fusion") { if (mode === "fusion") {
applyLayout(fusionLayoutRef.current) 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]) }, [applyLayout, mode])
const handleLayout = useCallback( const handleLayout = useCallback(
@@ -251,7 +248,6 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) {
function MobileWorkspaceContent({ children }: { children: React.ReactNode }) { function MobileWorkspaceContent({ children }: { children: React.ReactNode }) {
const { mode } = useWorkspaceContext() const { mode } = useWorkspaceContext()
// On mobile, fusion mode falls back to conversation view
const showConversation = mode === "conversation" || mode === "fusion" const showConversation = mode === "conversation" || mode === "fusion"
return ( return (
@@ -294,7 +290,6 @@ function MobileFolderWorkspaceShell({
return ( return (
<div className="flex flex-1 min-h-0 overflow-hidden"> <div className="flex flex-1 min-h-0 overflow-hidden">
{/* Sidebar Sheet (left) */}
<Sheet open={sidebarRestored && sidebarOpen} onOpenChange={toggleSidebar}> <Sheet open={sidebarRestored && sidebarOpen} onOpenChange={toggleSidebar}>
<SheetContent <SheetContent
side="left" side="left"
@@ -306,12 +301,10 @@ function MobileFolderWorkspaceShell({
</SheetContent> </SheetContent>
</Sheet> </Sheet>
{/* Main workspace */}
<main className="flex h-full min-h-0 w-full flex-col overflow-hidden"> <main className="flex h-full min-h-0 w-full flex-col overflow-hidden">
<MobileWorkspaceContent>{children}</MobileWorkspaceContent> <MobileWorkspaceContent>{children}</MobileWorkspaceContent>
</main> </main>
{/* Aux panel Sheet (right) */}
<Sheet open={auxRestored && auxOpen} onOpenChange={toggleAux}> <Sheet open={auxRestored && auxOpen} onOpenChange={toggleAux}>
<SheetContent <SheetContent
side="right" side="right"
@@ -323,7 +316,6 @@ function MobileFolderWorkspaceShell({
</SheetContent> </SheetContent>
</Sheet> </Sheet>
{/* Terminal Sheet (bottom) */}
<Sheet open={terminalOpen} onOpenChange={toggleTerminal}> <Sheet open={terminalOpen} onOpenChange={toggleTerminal}>
<SheetContent <SheetContent
side="bottom" side="bottom"
@@ -495,8 +487,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) {
shellGroup.setLayout(layout) shellGroup.setLayout(layout)
shellAppliedLayoutRef.current = layout shellAppliedLayoutRef.current = layout
} catch { } catch {
// The group can be transiently unavailable while registering panels. /* retry via onLayout */
// onLayout will retry once registration completes.
} }
}, []) }, [])
@@ -516,8 +507,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) {
mainGroup.setLayout(layout) mainGroup.setLayout(layout)
mainAppliedLayoutRef.current = layout mainAppliedLayoutRef.current = layout
} catch { } catch {
// The group can be transiently unavailable while registering panels. /* retry via onLayout */
// onLayout will retry once registration completes.
} }
}, []) }, [])
@@ -776,37 +766,23 @@ function FolderLayoutShell({ children }: { children: React.ReactNode }) {
) )
} }
function FolderLayoutInner({ children }: { children: React.ReactNode }) { function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams()
const folderId = Number(searchParams.get("id") ?? "0")
const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0
const conversationId = searchParams.get("conversationId")
const agentType = searchParams.get("agent") as AgentType | null
return ( return (
<FolderProvider <AppWorkspaceProvider>
folderId={normalizedFolderId} <ActiveFolderProvider>
initialConversationId={conversationId ? Number(conversationId) : null}
initialAgentType={agentType}
>
<FolderDocumentTitle />
<AlertProvider> <AlertProvider>
<GitCredentialProvider> <GitCredentialProvider>
<TaskProvider> <TaskProvider>
<AcpConnectionsProvider> <AcpConnectionsProvider>
<ConversationRuntimeProvider> <ConversationRuntimeProvider>
<WorkspaceProvider key={`workspace-${normalizedFolderId}`}> <WorkspaceProvider>
<TabProvider> <TabProvider>
<WorkspaceDocumentTitle />
<TabKeysSync /> <TabKeysSync />
<DeepLinkBootstrap />
<SessionStatsProvider> <SessionStatsProvider>
<SidebarProvider <SidebarProvider>
key={`left-sidebar-${normalizedFolderId}`} <AuxPanelProvider>
folderId={normalizedFolderId}
>
<AuxPanelProvider
key={`right-sidebar-${normalizedFolderId}`}
folderId={normalizedFolderId}
>
<TerminalProvider> <TerminalProvider>
<FolderLayoutShell>{children}</FolderLayoutShell> <FolderLayoutShell>{children}</FolderLayoutShell>
</TerminalProvider> </TerminalProvider>
@@ -820,18 +796,19 @@ function FolderLayoutInner({ children }: { children: React.ReactNode }) {
</TaskProvider> </TaskProvider>
</GitCredentialProvider> </GitCredentialProvider>
</AlertProvider> </AlertProvider>
</FolderProvider> </ActiveFolderProvider>
</AppWorkspaceProvider>
) )
} }
export default function FolderLayout({ export default function WorkspaceLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
<Suspense> <Suspense>
<FolderLayoutInner>{children}</FolderLayoutInner> <WorkspaceLayoutInner>{children}</WorkspaceLayoutInner>
</Suspense> </Suspense>
) )
} }
+15
View File
@@ -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 <WorkspaceEmpty />
}
return <ConversationDetailPanel />
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"
import { openUrl } from "@/lib/platform" import { openUrl } from "@/lib/platform"
import type { LinkSafetyConfig, LinkSafetyModalProps } from "streamdown" import type { LinkSafetyConfig, LinkSafetyModalProps } from "streamdown"
import { toast } from "sonner" import { toast } from "sonner"
import { useFolderContext } from "@/contexts/folder-context" import { useActiveFolder } from "@/contexts/active-folder-context"
import { useWorkspaceContext } from "@/contexts/workspace-context" import { useWorkspaceContext } from "@/contexts/workspace-context"
import { import {
AlertDialog, AlertDialog,
@@ -223,7 +223,7 @@ function LinkSafetyModal({
export function useStreamdownLinkSafety(): LinkSafetyConfig { export function useStreamdownLinkSafety(): LinkSafetyConfig {
const t = useTranslations("Folder.chat.linkSafety") const t = useTranslations("Folder.chat.linkSafety")
const { folder } = useFolderContext() const { activeFolder: folder } = useActiveFolder()
const folderPath = folder?.path const folderPath = folder?.path
const { openFilePreview } = useWorkspaceContext() const { openFilePreview } = useWorkspaceContext()
+2
View File
@@ -12,6 +12,7 @@ import type {
AvailableCommandInfo, AvailableCommandInfo,
} from "@/lib/types" } from "@/lib/types"
import type { QueuedMessage } from "@/hooks/use-message-queue" import type { QueuedMessage } from "@/hooks/use-message-queue"
import { ConversationContextBar } from "@/components/chat/conversation-context-bar"
import { MessageInput } from "@/components/chat/message-input" import { MessageInput } from "@/components/chat/message-input"
import { MessageQueueDisplay } from "@/components/chat/message-queue-display" import { MessageQueueDisplay } from "@/components/chat/message-queue-display"
@@ -92,6 +93,7 @@ export const ChatInput = memo(function ChatInput({
className="p-4 pt-0" className="p-4 pt-0"
onContextMenu={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()}
> >
<ConversationContextBar />
{queue && {queue &&
queue.length > 0 && queue.length > 0 &&
onQueueReorder && onQueueReorder &&
@@ -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 (
<TooltipProvider>
<div className="flex items-center gap-1.5 h-9 px-3 border-b border-border/40 bg-muted/20 text-xs">
<FolderPicker
folders={allFolders}
currentFolderId={activeFolder.id}
currentFolderName={activeFolder.name}
editable={isNewConversation}
onSelect={async (folderId) => {
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")}
/>
<Separator orientation="vertical" className="h-4" />
<BranchPicker
folderId={activeFolder.id}
folderPath={activeFolder.path}
currentBranch={currentBranch}
onCheckout={async (branchName) => {
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)
}
}}
/>
<div className="flex-1" />
<GitActionButtons
folderId={activeFolder.id}
currentBranch={currentBranch}
/>
</div>
</TooltipProvider>
)
})
ConversationContextBar.displayName = "ConversationContextBar"
// ============================================================================
// FolderPicker
// ============================================================================
interface FolderPickerProps {
folders: { id: number; name: string; path: string }[]
currentFolderId: number
currentFolderName: string
editable: boolean
onSelect: (folderId: number) => void | Promise<void>
onOpenNewFolder: () => void | Promise<void>
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 = (
<Button
variant="ghost"
size="sm"
className={cn(
"h-7 px-2 gap-1.5 font-normal",
!editable && "cursor-default hover:bg-transparent"
)}
disabled={!editable && false}
>
<Folder className="h-3.5 w-3.5 text-muted-foreground" />
<span className="max-w-[140px] truncate">{currentFolderName}</span>
{editable && (
<ChevronsUpDown className="h-3 w-3 text-muted-foreground opacity-60" />
)}
</Button>
)
if (!editable) {
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="bottom">{currentFolderName}</TooltipContent>
</Tooltip>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent align="start" className="p-0 w-72">
<Command>
<CommandInput placeholder={labelSearch} />
<CommandList>
<CommandEmpty>{labelEmpty}</CommandEmpty>
<CommandGroup>
{folders.map((f) => (
<CommandItem
key={f.id}
value={`${f.name} ${f.path}`}
onSelect={() => {
setOpen(false)
void onSelect(f.id)
}}
>
<Folder className="h-4 w-4" />
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate font-medium">{f.name}</span>
<span className="truncate text-xs text-muted-foreground">
{f.path}
</span>
</div>
{f.id === currentFolderId && (
<Check className="h-4 w-4 shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
setOpen(false)
void onOpenNewFolder()
}}
>
<FolderOpen className="h-4 w-4" />
{labelOpenNew}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
})
// ============================================================================
// BranchPicker
// ============================================================================
interface BranchPickerProps {
folderId: number
folderPath: string
currentBranch: string | null
onCheckout: (branchName: string) => Promise<void>
onNewBranch: (branchName: string, startPoint?: string) => Promise<void>
onDeleteBranch: (branchName: string) => Promise<void>
}
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<GitBranchList | null>(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 (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 gap-1.5 font-normal"
>
<GitBranch className="h-3.5 w-3.5 text-muted-foreground" />
<span className="max-w-[160px] truncate">
{currentBranch ?? t("noBranch")}
</span>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0 w-80">
<Command>
<CommandInput placeholder={t("searchBranch")} />
<CommandList>
{loading ? (
<div className="py-6 text-center text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin mx-auto" />
</div>
) : (
<>
<CommandEmpty>{t("noBranches")}</CommandEmpty>
{branchList && branchList.local.length > 0 && (
<CommandGroup
heading={tBd("localBranches", {
count: branchList.local.length,
})}
>
{branchList.local.map((b) => (
<CommandItem
key={`local-${b}`}
value={`local ${b}`}
onSelect={() => {
setOpen(false)
if (b !== currentBranch) void onCheckout(b)
}}
>
<GitBranch className="h-4 w-4" />
<span className="flex-1 truncate">{b}</span>
{b === currentBranch && (
<Check className="h-4 w-4 shrink-0" />
)}
{b !== currentBranch && (
<Trash2
className="h-3.5 w-3.5 opacity-50 hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
setOpen(false)
void onDeleteBranch(b)
}}
/>
)}
</CommandItem>
))}
</CommandGroup>
)}
{branchList && branchList.remote.length > 0 && (
<CommandGroup
heading={tBd("remoteBranches", {
count: branchList.remote.length,
})}
>
{branchList.remote.map((b) => (
<CommandItem
key={`remote-${b}`}
value={`remote ${b}`}
onSelect={() => {
setOpen(false)
void onCheckout(b)
}}
>
<GitBranch className="h-4 w-4 opacity-60" />
<span className="flex-1 truncate text-muted-foreground">
{b}
</span>
</CommandItem>
))}
</CommandGroup>
)}
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
setOpen(false)
setNewBranchName("")
setNewBranchOpen(true)
}}
>
<Plus className="h-4 w-4" />
{tBd("newBranch")}
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Dialog open={newBranchOpen} onOpenChange={setNewBranchOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{tBd("dialogs.newBranchTitle")}</DialogTitle>
</DialogHeader>
<div className="text-sm text-muted-foreground">
{tBd("dialogs.newBranchDescription", {
branch: currentBranch ?? "-",
})}
</div>
<Input
placeholder={tBd("dialogs.branchNamePlaceholder")}
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
autoFocus
/>
<DialogFooter>
<Button variant="outline" onClick={() => setNewBranchOpen(false)}>
{t("cancel")}
</Button>
<Button
disabled={!newBranchName.trim()}
onClick={async () => {
const name = newBranchName.trim()
if (!name) return
setNewBranchOpen(false)
await onNewBranch(name, currentBranch ?? undefined)
}}
>
{t("create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
})
// ============================================================================
// 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 (
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 gap-1.5 font-normal"
onClick={handleCommit}
disabled={currentBranch == null}
>
<GitCommit className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t("commit")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{tBd("openCommitWindow")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 gap-1.5 font-normal"
onClick={handlePush}
disabled={currentBranch == null}
>
<Upload className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t("push")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{tBd("pushCode")}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
disabled={currentBranch == null}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={handleMerge}>
<GitMerge className="h-4 w-4" />
{t("merge")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleStash}>
<Archive className="h-4 w-4" />
{tBd("stashChanges")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleCommit}>
<GitCommit className="h-4 w-4" />
{tBd("openCommitWindow")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={handlePush}>
<Upload className="h-4 w-4" />
{tBd("pushCode")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})
@@ -23,7 +23,8 @@ import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
import { disposeTauriListener } from "@/lib/tauri-listener" import { disposeTauriListener } from "@/lib/tauri-listener"
import { useAcpActions } from "@/contexts/acp-connections-context" 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 { useTabContext } from "@/contexts/tab-context"
import { useSessionStats } from "@/contexts/session-stats-context" import { useSessionStats } from "@/contexts/session-stats-context"
import { useTaskContext } from "@/contexts/task-context" import { useTaskContext } from "@/contexts/task-context"
@@ -159,8 +160,9 @@ const ConversationTabView = memo(function ConversationTabView({
const t = useTranslations("Folder.conversation") const t = useTranslations("Folder.conversation")
const tWelcome = useTranslations("Folder.chat.welcomeInputPanel") const tWelcome = useTranslations("Folder.chat.welcomeInputPanel")
const sharedT = useTranslations("Folder.chat.shared") const sharedT = useTranslations("Folder.chat.shared")
const { folder, folderId, refreshConversations, updateConversationLocal } = const { activeFolder: folder, activeFolderId } = useActiveFolder()
useFolderContext() const { refreshConversations, updateConversationLocal } = useAppWorkspace()
const folderId = activeFolderId ?? 0
const { tabs, bindConversationTab, setTabRuntimeConversationId, pinTab } = const { tabs, bindConversationTab, setTabRuntimeConversationId, pinTab } =
useTabContext() useTabContext()
const { setSessionStats } = useSessionStats() const { setSessionStats } = useSessionStats()
@@ -267,8 +269,8 @@ const ConversationTabView = memo(function ConversationTabView({
if (dbConversationId != null) { if (dbConversationId != null) {
return buildConversationDraftStorageKey(selectedAgent, dbConversationId) return buildConversationDraftStorageKey(selectedAgent, dbConversationId)
} }
return buildNewConversationDraftStorageKey({ folderId }) return buildNewConversationDraftStorageKey({ tabId })
}, [dbConversationId, folderId, selectedAgent]) }, [dbConversationId, tabId, selectedAgent])
const workingDirForConnection = useMemo(() => { const workingDirForConnection = useMemo(() => {
if (dbConversationId != null) { if (dbConversationId != null) {
return detailLoading ? undefined : folder?.path return detailLoading ? undefined : folder?.path
@@ -624,7 +626,7 @@ const ConversationTabView = memo(function ConversationTabView({
effectiveConversationId effectiveConversationId
) )
moveMessageInputDraft( moveMessageInputDraft(
buildNewConversationDraftStorageKey({ folderId }), buildNewConversationDraftStorageKey({ tabId }),
buildConversationDraftStorageKey(selectedAgent, newConversationId) buildConversationDraftStorageKey(selectedAgent, newConversationId)
) )
statusUpdatedRef.current = false statusUpdatedRef.current = false
@@ -1034,13 +1036,9 @@ export function ConversationDetailPanel() {
getSession, getSession,
removeConversation: runtimeRemoveConversation, removeConversation: runtimeRemoveConversation,
} = useConversationRuntime() } = useConversationRuntime()
const { const { activeFolder: folder } = useActiveFolder()
folder, const { conversations, refreshConversations, updateConversationLocal } =
newConversation, useAppWorkspace()
conversations,
refreshConversations,
updateConversationLocal,
} = useFolderContext()
const { const {
tabs, tabs,
activeTabId, activeTabId,
@@ -1050,6 +1048,13 @@ export function ConversationDetailPanel() {
switchTab, switchTab,
onPreviewTabReplaced, onPreviewTabReplaced,
} = useTabContext() } = 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 { disconnect: disconnectByKey } = useAcpActions()
const { addTask, updateTask } = useTaskContext() const { addTask, updateTask } = useTaskContext()
const [reloadByTabId, setReloadByTabId] = useState<Record<string, number>>({}) const [reloadByTabId, setReloadByTabId] = useState<Record<string, number>>({})
@@ -1298,7 +1303,7 @@ export function ConversationDetailPanel() {
const handleNewConversation = useCallback(() => { const handleNewConversation = useCallback(() => {
if (!folder) return if (!folder) return
openNewConversationTab(folder.path) openNewConversationTab(folder.id, folder.path)
}, [folder, openNewConversationTab]) }, [folder, openNewConversationTab])
const handleCloseActiveTab = useCallback(() => { const handleCloseActiveTab = useCallback(() => {
@@ -1372,7 +1377,10 @@ export function ConversationDetailPanel() {
if (!folder) return if (!folder) return
if (hasNoTabs) { if (hasNoTabs) {
openNewConversationTab(newConversation?.workingDir ?? folder.path) openNewConversationTab(
folder.id,
newConversation?.workingDir ?? folder.path
)
} }
}, [folder, hasNoTabs, newConversation?.workingDir, openNewConversationTab]) }, [folder, hasNoTabs, newConversation?.workingDir, openNewConversationTab])
@@ -6,10 +6,11 @@ import { enUS, zhCN, zhTW } from "date-fns/locale"
import { File, Folder } from "lucide-react" import { File, Folder } from "lucide-react"
import { useLocale, useTranslations } from "next-intl" import { useLocale, useTranslations } from "next-intl"
import { useAuxPanelContext } from "@/contexts/aux-panel-context" 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 { useTabContext } from "@/contexts/tab-context"
import { useWorkspaceContext } from "@/contexts/workspace-context" import { useWorkspaceContext } from "@/contexts/workspace-context"
import { listFolderConversations } from "@/lib/api" import { listAllConversations } from "@/lib/api"
import type { import type {
AgentType, AgentType,
ConversationStatus, ConversationStatus,
@@ -43,7 +44,16 @@ export function SearchCommandDialog({
const locale = useLocale() const locale = useLocale()
const dateFnsLocale = const dateFnsLocale =
locale === "zh-CN" ? zhCN : locale === "zh-TW" ? zhTW : enUS 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 { openTab } = useTabContext()
const { openFilePreview } = useWorkspaceContext() const { openFilePreview } = useWorkspaceContext()
const { revealInFileTree } = useAuxPanelContext() const { revealInFileTree } = useAuxPanelContext()
@@ -96,8 +106,8 @@ export function SearchCommandDialog({
} }
setSearching(true) setSearching(true)
try { try {
const data = await listFolderConversations({ const data = await listAllConversations({
folder_id: folderId, folder_ids: folderId > 0 ? [folderId] : null,
search: q.trim() || null, search: q.trim() || null,
agent_type: agent, agent_type: agent,
}) })
@@ -136,7 +146,7 @@ export function SearchCommandDialog({
const handleSelectConversation = useCallback( const handleSelectConversation = useCallback(
(conv: DbConversationSummary) => { (conv: DbConversationSummary) => {
openTab(conv.id, conv.agent_type, true) openTab(conv.folder_id, conv.id, conv.agent_type, true)
onOpenChange(false) onOpenChange(false)
}, },
[openTab, onOpenChange] [openTab, onOpenChange]
@@ -13,8 +13,16 @@ import {
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
import { Virtualizer, type VirtualizerHandle } from "virtua" import { Virtualizer, type VirtualizerHandle } from "virtua"
import { CheckCheck, ChevronRight, Download, Loader2, Plus } from "lucide-react" import {
import { useFolderContext } from "@/contexts/folder-context" 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 { useTabContext } from "@/contexts/tab-context"
import { useTaskContext } from "@/contexts/task-context" import { useTaskContext } from "@/contexts/task-context"
import { import {
@@ -25,6 +33,11 @@ import {
} from "@/lib/api" } from "@/lib/api"
import type { ConversationStatus, DbConversationSummary } from "@/lib/types" import type { ConversationStatus, DbConversationSummary } from "@/lib/types"
import { STATUS_ORDER, STATUS_COLORS } 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 { SidebarConversationCard } from "./sidebar-conversation-card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@@ -69,12 +82,99 @@ function compareByUpdatedAtDesc(
} }
type FlatItem = 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 } | { type: "conversation"; conversation: DbConversationSummary }
const CARD_HEIGHT = 62 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<typeof useTranslations>
}) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
data-folder-id={folderId}
onClick={() => onToggle(folderId)}
className={cn(
"flex items-center gap-1.5 w-full px-1.5 py-1.5 text-xs font-medium cursor-pointer transition-all",
"text-foreground hover:bg-accent/50 rounded-sm",
highlighted && "ring-2 ring-primary ring-offset-1"
)}
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 shrink-0 transition-transform text-muted-foreground",
expanded && "rotate-90"
)}
/>
<span className="truncate flex-1 text-left">{folderName}</span>
{branch && (
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
{branch}
</span>
)}
<span className="text-muted-foreground/60 tabular-nums text-[10px]">
({count})
</span>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onFocus(folderId)}>
{t("folderHeaderMenu.focus")}
</ContextMenuItem>
<ContextMenuItem onSelect={() => onCloseFolderTabs(folderId)}>
{t("folderHeaderMenu.closeFolderTabs")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onSelect={() => onRemoveFromWorkspace(folderId)}
>
<XCircle className="h-4 w-4" />
{t("folderHeaderMenu.removeFromWorkspace")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})
const StatusHeader = memo(function StatusHeader({
status, status,
count, count,
isOpen, isOpen,
@@ -168,32 +268,62 @@ export interface SidebarConversationListHandle {
scrollToActive: () => void scrollToActive: () => void
expandAll: () => void expandAll: () => void
collapseAll: () => void collapseAll: () => void
revealFolder: (folderId: number) => void
}
export interface SidebarConversationListProps {
viewMode?: SidebarViewMode
searchQuery?: string
} }
export function SidebarConversationList({ export function SidebarConversationList({
ref, ref,
}: { viewMode = "flat",
searchQuery = "",
}: SidebarConversationListProps & {
ref?: Ref<SidebarConversationListHandle> ref?: Ref<SidebarConversationListHandle>
}) { }) {
const t = useTranslations("Folder.sidebar") const t = useTranslations("Folder.sidebar")
const tStatus = useTranslations("Folder.statusLabels") const tStatus = useTranslations("Folder.statusLabels")
const tCommon = useTranslations("Folder.common") const tCommon = useTranslations("Folder.common")
const { const {
folder, allFolders,
conversations, conversations,
loading, conversationsLoading: loading,
refreshing, conversationsError: error,
error,
selectedConversation,
folderId,
refreshConversations, refreshConversations,
updateConversationLocal, updateConversationLocal,
} = useFolderContext() branches,
removeFolderFromWorkspace,
} = useAppWorkspace()
const refreshing = loading
const { activeFolder } = useActiveFolder()
const { openTab, closeConversationTab, openNewConversationTab } = const {
useTabContext() openTab,
closeConversationTab,
closeTabsByFolder,
openNewConversationTab,
activeTabId,
tabs,
} = useTabContext()
const { addTask, updateTask } = useTaskContext() const { addTask, updateTask } = useTaskContext()
const folderIndex = useMemo(() => {
const map = new Map<number, { name: string; path: string }>()
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 [importing, setImporting] = useState(false)
const [completeReviewOpen, setCompleteReviewOpen] = useState(false) const [completeReviewOpen, setCompleteReviewOpen] = useState(false)
const [completingReview, setCompletingReview] = useState(false) const [completingReview, setCompletingReview] = useState(false)
@@ -205,10 +335,160 @@ export function SidebarConversationList({
completed: false, completed: false,
cancelled: false, cancelled: false,
}) })
const [folderExpanded, setFolderExpanded] = useState<Record<number, boolean>>(
{}
)
const [highlightedFolder, setHighlightedFolder] = useState<number | null>(
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 scrollToActiveRef = useRef<() => void>(() => {})
const pendingScrollRef = useRef(false) const pendingScrollRef = useRef(false)
const virtualizerRef = useRef<VirtualizerHandle>(null) const virtualizerRef = useRef<VirtualizerHandle>(null)
const highlightTimerRef = useRef<number | null>(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<ConversationStatus, DbConversationSummary[]>()
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<ConversationStatus, DbConversationSummary[]>
>()
for (const conv of filteredConversations) {
const folderId = conv.folder_id
let inner = map.get(folderId)
if (!inner) {
inner = new Map<ConversationStatus, DbConversationSummary[]>()
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<number>()
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<FlatItem[]>(() => {
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, () => ({ useImperativeHandle(ref, () => ({
scrollToActive() { scrollToActive() {
@@ -230,45 +510,42 @@ export function SidebarConversationList({
cancelled: false, 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(() => { useEffect(() => {
const map = new Map<ConversationStatus, DbConversationSummary[]>() return () => {
for (const conv of conversations) { if (highlightTimerRef.current) {
const status = conv.status as ConversationStatus window.clearTimeout(highlightTimerRef.current)
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
}, [conversations])
const flatItems = useMemo<FlatItem[]>(() => {
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(() => { useEffect(() => {
scrollToActiveRef.current = () => { scrollToActiveRef.current = () => {
@@ -285,6 +562,15 @@ export function SidebarConversationList({
pendingScrollRef.current = true pendingScrollRef.current = true
return 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( const index = flatItems.findIndex(
(item) => (item) =>
item.type === "conversation" && item.type === "conversation" &&
@@ -303,12 +589,72 @@ export function SidebarConversationList({
pendingScrollRef.current = false pendingScrollRef.current = false
scrollToActiveRef.current() scrollToActiveRef.current()
} }
}, [selectedConversation, flatItems, conversations, groupExpanded]) }, [
selectedConversation,
flatItems,
conversations,
groupExpanded,
folderExpanded,
viewMode,
])
const toggleGroup = useCallback((status: ConversationStatus) => { const toggleGroup = useCallback((status: ConversationStatus) => {
setGroupExpanded((prev) => ({ ...prev, [status]: !prev[status] })) 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( const handleOpenCompleteReview = useCallback(
() => setCompleteReviewOpen(true), () => setCompleteReviewOpen(true),
[] []
@@ -316,16 +662,34 @@ export function SidebarConversationList({
const handleSelect = useCallback( const handleSelect = useCallback(
(id: number, agentType: string) => { (id: number, agentType: string) => {
openTab(id, agentType as Parameters<typeof openTab>[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<typeof openTab>[2],
false
)
}, },
[openTab] [openTab, conversations]
) )
const handleDoubleClick = useCallback( const handleDoubleClick = useCallback(
(id: number, agentType: string) => { (id: number, agentType: string) => {
openTab(id, agentType as Parameters<typeof openTab>[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<typeof openTab>[2],
true
)
}, },
[openTab] [openTab, conversations]
) )
const handleRename = useCallback( const handleRename = useCallback(
@@ -338,11 +702,20 @@ export function SidebarConversationList({
const handleDelete = useCallback( const handleDelete = useCallback(
async (id: number, agentType: string) => { async (id: number, agentType: string) => {
const conv = conversations.find(
(c) => c.id === id && c.agent_type === agentType
)
await deleteConversation(id) await deleteConversation(id)
closeConversationTab(id, agentType as Parameters<typeof openTab>[1]) if (conv) {
closeConversationTab(
conv.folder_id,
id,
agentType as Parameters<typeof openTab>[2]
)
}
refreshConversations() refreshConversations()
}, },
[closeConversationTab, refreshConversations] [closeConversationTab, refreshConversations, conversations]
) )
const handleStatusChange = useCallback( const handleStatusChange = useCallback(
@@ -354,18 +727,19 @@ export function SidebarConversationList({
) )
const handleNewConversation = useCallback(() => { const handleNewConversation = useCallback(() => {
if (!folder) return if (!activeFolder) return
openNewConversationTab(folder.path) openNewConversationTab(activeFolder.id, activeFolder.path)
}, [folder, openNewConversationTab]) }, [activeFolder, openNewConversationTab])
const handleImport = useCallback(async () => { const handleImport = useCallback(async () => {
if (importing) return if (importing) return
if (!activeFolder) return
setImporting(true) setImporting(true)
const taskId = `import-${folderId}-${Date.now()}` const taskId = `import-${activeFolder.id}-${Date.now()}`
addTask(taskId, t("importLocalSessions")) addTask(taskId, t("importLocalSessions"))
updateTask(taskId, { status: "running" }) updateTask(taskId, { status: "running" })
try { try {
const result = await importLocalConversations(folderId) const result = await importLocalConversations(activeFolder.id)
updateTask(taskId, { status: "completed" }) updateTask(taskId, { status: "completed" })
refreshConversations() refreshConversations()
if (result.imported > 0) { if (result.imported > 0) {
@@ -385,13 +759,12 @@ export function SidebarConversationList({
} finally { } finally {
setImporting(false) setImporting(false)
} }
}, [importing, folderId, addTask, updateTask, refreshConversations, t]) }, [importing, activeFolder, addTask, updateTask, refreshConversations, t])
const handleCompleteAllReview = useCallback(async () => { const handleCompleteAllReview = useCallback(async () => {
if (completingReview || reviewConversationCount === 0) return if (completingReview || reviewConversationCount === 0) return
setCompletingReview(true) setCompletingReview(true)
try { try {
// Optimistic: update all locally first
for (const conversation of reviewConversations) { for (const conversation of reviewConversations) {
updateConversationLocal(conversation.id, { status: "completed" }) updateConversationLocal(conversation.id, { status: "completed" })
} }
@@ -407,7 +780,6 @@ export function SidebarConversationList({
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
toast.error(t("toasts.completeReviewFailed", { message: msg })) toast.error(t("toasts.completeReviewFailed", { message: msg }))
// Revert on error — refetch from server
refreshConversations() refreshConversations()
} finally { } finally {
setCompletingReview(false) setCompletingReview(false)
@@ -421,6 +793,9 @@ export function SidebarConversationList({
t, t,
]) ])
const emptyAfterSearch =
filteredConversations.length === 0 && normalizedSearch.length > 0
return ( return (
<div className="relative flex flex-col flex-1 min-h-0"> <div className="relative flex flex-col flex-1 min-h-0">
{(loading || refreshing) && ( {(loading || refreshing) && (
@@ -451,7 +826,7 @@ export function SidebarConversationList({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={importing} disabled={importing || !activeFolder}
onClick={handleImport} onClick={handleImport}
> >
{importing ? ( {importing ? (
@@ -464,17 +839,29 @@ export function SidebarConversationList({
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem onSelect={handleNewConversation}> <ContextMenuItem
onSelect={handleNewConversation}
disabled={!activeFolder}
>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
{t("newConversation")} {t("newConversation")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem disabled={importing} onSelect={handleImport}> <ContextMenuItem
disabled={importing || !activeFolder}
onSelect={handleImport}
>
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
{importing ? t("importing") : t("importLocalSessions")} {importing ? t("importing") : t("importLocalSessions")}
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
) : emptyAfterSearch ? (
<div className="flex-1 flex items-center justify-center px-3">
<p className="text-muted-foreground text-xs text-center">
{t("noMatchingConversations")}
</p>
</div>
) : ( ) : (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
@@ -483,16 +870,36 @@ export function SidebarConversationList({
className={cn("h-full min-h-0 px-2", "[overflow-anchor:none]")} className={cn("h-full min-h-0 px-2", "[overflow-anchor:none]")}
> >
<Virtualizer ref={virtualizerRef} itemSize={CARD_HEIGHT}> <Virtualizer ref={virtualizerRef} itemSize={CARD_HEIGHT}>
{flatItems.map((item) => { {flatItems.map((item, index) => {
const key = if (item.type === "folder_header") {
item.type === "header"
? `header-${item.status}`
: `conv-${item.conversation.id}`
return ( return (
<div key={key}> <FolderHeader
{item.type === "header" ? ( key={`folder-${item.folderId}`}
folderId={item.folderId}
folderName={item.folderName}
branch={item.branch}
count={item.count}
expanded={item.expanded}
onToggle={toggleFolder}
onFocus={focusFolder}
onCloseFolderTabs={handleCloseFolderTabs}
onRemoveFromWorkspace={handleRemoveFolder}
highlighted={highlightedFolder === item.folderId}
t={t}
/>
)
}
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" ? ( item.status === "pending_review" ? (
<PendingReviewHeader <PendingReviewHeader
key={key}
count={item.count} count={item.count}
isOpen={groupExpanded[item.status]} isOpen={groupExpanded[item.status]}
onToggle={toggleGroup} onToggle={toggleGroup}
@@ -503,7 +910,8 @@ export function SidebarConversationList({
t={t} t={t}
/> />
) : ( ) : (
<GroupHeader <StatusHeader
key={key}
status={item.status} status={item.status}
count={item.count} count={item.count}
isOpen={groupExpanded[item.status]} isOpen={groupExpanded[item.status]}
@@ -511,13 +919,21 @@ export function SidebarConversationList({
tStatus={tStatus} tStatus={tStatus}
/> />
) )
return indented ? (
<div key={key} className="pl-4">
{headerNode}
</div>
) : ( ) : (
headerNode
)
}
const conv = item.conversation
const cardNode = (
<SidebarConversationCard <SidebarConversationCard
conversation={item.conversation} conversation={conv}
isSelected={ isSelected={
selectedConversation?.agentType === selectedConversation?.agentType === conv.agent_type &&
item.conversation.agent_type && selectedConversation?.id === conv.id
selectedConversation?.id === item.conversation.id
} }
onSelect={handleSelect} onSelect={handleSelect}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
@@ -528,8 +944,13 @@ export function SidebarConversationList({
onImport={handleImport} onImport={handleImport}
importing={importing} importing={importing}
/> />
)} )
return indented ? (
<div key={`conv-${conv.id}`} className="pl-4">
{cardNode}
</div> </div>
) : (
<div key={`conv-${conv.id}`}>{cardNode}</div>
) )
})} })}
</Virtualizer> </Virtualizer>
@@ -537,12 +958,18 @@ export function SidebarConversationList({
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem onSelect={handleNewConversation}> <ContextMenuItem
onSelect={handleNewConversation}
disabled={!activeFolder}
>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
{t("newConversation")} {t("newConversation")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem disabled={importing} onSelect={handleImport}> <ContextMenuItem
disabled={importing || !activeFolder}
onSelect={handleImport}
>
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
{importing ? t("importing") : t("importLocalSessions")} {importing ? t("importing") : t("importLocalSessions")}
</ContextMenuItem> </ContextMenuItem>
@@ -577,6 +1004,28 @@ export function SidebarConversationList({
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<AlertDialog
open={removeConfirm !== null}
onOpenChange={(open) => !open && setRemoveConfirm(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("removeFolderConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("removeFolderConfirmDescription", {
name: removeConfirm?.folderName ?? "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleRemoveFolderConfirm}>
{tCommon("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
) )
} }
+2 -2
View File
@@ -2,7 +2,7 @@
import { useMemo } from "react" import { useMemo } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { useFolderContext } from "@/contexts/folder-context" import { useActiveFolder } from "@/contexts/active-folder-context"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
@@ -529,7 +529,7 @@ export function UnifiedDiffPreview({
className?: string className?: string
}) { }) {
const t = useTranslations("Folder.diffPreview") const t = useTranslations("Folder.diffPreview")
const { folder } = useFolderContext() const { activeFolder: folder } = useActiveFolder()
const files = useMemo(() => parseUnifiedDiff(diffText), [diffText]) const files = useMemo(() => parseUnifiedDiff(diffText), [diffText])
if (!diffText.trim()) { if (!diffText.trim()) {
@@ -5,7 +5,7 @@ import dynamic from "next/dynamic"
import { ChevronDown, ChevronRight, FileCode2, FileIcon } from "lucide-react" import { ChevronDown, ChevronRight, FileCode2, FileIcon } from "lucide-react"
import type { editor as MonacoEditorNs } from "monaco-editor" import type { editor as MonacoEditorNs } from "monaco-editor"
import { useTranslations } from "next-intl" 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 { useWorkspaceContext } from "@/contexts/workspace-context"
import { ImagePreview } from "@/components/files/image-preview" import { ImagePreview } from "@/components/files/image-preview"
import { DiffViewer } from "@/components/diff/diff-viewer" import { DiffViewer } from "@/components/diff/diff-viewer"
@@ -766,7 +766,7 @@ export function FileWorkspacePanel() {
saveActiveFile, saveActiveFile,
updateActiveFileContent, updateActiveFileContent,
} = useWorkspaceContext() } = useWorkspaceContext()
const { folder } = useFolderContext() const { activeFolder: folder } = useActiveFolder()
const folderPath = folder?.path ?? null const folderPath = folder?.path ?? null
const activeScope = activeFileTab?.id ?? "__default__" const activeScope = activeFileTab?.id ?? "__default__"
const editorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(null) const editorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(null)
@@ -5,7 +5,7 @@ import { Reorder } from "motion/react"
import { Code, Eye, ExternalLink, FileText, GitCompare, X } from "lucide-react" import { Code, Eye, ExternalLink, FileText, GitCompare, X } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { openPath } from "@/lib/platform" 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 { useWorkspaceContext } from "@/contexts/workspace-context"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
@@ -33,7 +33,7 @@ export function FileWorkspaceTabBar() {
previewFileTabIds, previewFileTabIds,
toggleFileTabPreview, toggleFileTabPreview,
} = useWorkspaceContext() } = useWorkspaceContext()
const { folder } = useFolderContext() const { activeFolder: folder } = useActiveFolder()
const { shortcuts } = useShortcutSettings() const { shortcuts } = useShortcutSettings()
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
@@ -13,7 +13,7 @@ import ignore from "ignore"
import { Check, ChevronRight } from "lucide-react" import { Check, ChevronRight } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" 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 { useAuxPanelContext } from "@/contexts/aux-panel-context"
import { useTabContext } from "@/contexts/tab-context" import { useTabContext } from "@/contexts/tab-context"
import { useTerminalContext } from "@/contexts/terminal-context" import { useTerminalContext } from "@/contexts/terminal-context"
@@ -797,7 +797,7 @@ export function FileTreeTab() {
const t = useTranslations("Folder.fileTreeTab") const t = useTranslations("Folder.fileTreeTab")
const tCommon = useTranslations("Folder.common") const tCommon = useTranslations("Folder.common")
const { pendingRevealPath, consumePendingRevealPath } = useAuxPanelContext() const { pendingRevealPath, consumePendingRevealPath } = useAuxPanelContext()
const { folder } = useFolderContext() const { activeFolder: folder } = useActiveFolder()
const { tabs, activeTabId } = useTabContext() const { tabs, activeTabId } = useTabContext()
const { createTerminalInDirectory } = useTerminalContext() const { createTerminalInDirectory } = useTerminalContext()
const { const {
@@ -34,7 +34,7 @@ import {
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu" } from "@/components/ui/context-menu"
import { Skeleton } from "@/components/ui/skeleton" 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 { useTabContext } from "@/contexts/tab-context"
import { useWorkspaceContext } from "@/contexts/workspace-context" import { useWorkspaceContext } from "@/contexts/workspace-context"
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store" import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
@@ -378,7 +378,7 @@ export function GitChangesTab() {
const t = useTranslations("Folder.gitChangesTab") const t = useTranslations("Folder.gitChangesTab")
const tCommon = useTranslations("Folder.common") const tCommon = useTranslations("Folder.common")
const tFileTree = useTranslations("Folder.fileTreeTab") const tFileTree = useTranslations("Folder.fileTreeTab")
const { folder } = useFolderContext() const { activeFolder: folder } = useActiveFolder()
const { tabs, activeTabId } = useTabContext() const { tabs, activeTabId } = useTabContext()
const { openFilePreview, openWorkingTreeDiff } = useWorkspaceContext() const { openFilePreview, openWorkingTreeDiff } = useWorkspaceContext()
const workspaceState = useWorkspaceStateStore(folder?.path ?? null) const workspaceState = useWorkspaceStateStore(folder?.path ?? null)
@@ -77,7 +77,7 @@ import {
} from "@/components/ui/collapsible" } from "@/components/ui/collapsible"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { subscribe } from "@/lib/platform" 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 { useWorkspaceContext } from "@/contexts/workspace-context"
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store" import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
import { import {
@@ -691,7 +691,7 @@ function BranchSelector({
export function GitLogTab() { export function GitLogTab() {
const t = useTranslations("Folder.gitLogTab") const t = useTranslations("Folder.gitLogTab")
const tCommon = useTranslations("Folder.common") const tCommon = useTranslations("Folder.common")
const { folder } = useFolderContext() const { activeFolder: folder } = useActiveFolder()
const { openCommitDiff, openFilePreview } = useWorkspaceContext() const { openCommitDiff, openFilePreview } = useWorkspaceContext()
const workspaceState = useWorkspaceStateStore(folder?.path ?? null) const workspaceState = useWorkspaceStateStore(folder?.path ?? null)
const isGitRepo = workspaceState.isGitRepo const isGitRepo = workspaceState.isGitRepo
@@ -3,7 +3,7 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { ChevronRight, FileIcon } from "lucide-react" import { ChevronRight, FileIcon } from "lucide-react"
import { useTranslations } from "next-intl" 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 { useTabContext } from "@/contexts/tab-context"
import { useConversationRuntime } from "@/contexts/conversation-runtime-context" import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
import { useWorkspaceContext } from "@/contexts/workspace-context" import { useWorkspaceContext } from "@/contexts/workspace-context"
@@ -58,7 +58,7 @@ function SessionFilesContent({ conversationId }: { conversationId: number }) {
const { loading } = useConversationDetail(conversationId) const { loading } = useConversationDetail(conversationId)
const { getTimelineTurns } = useConversationRuntime() const { getTimelineTurns } = useConversationRuntime()
const { openSessionFileDiff } = useWorkspaceContext() const { openSessionFileDiff } = useWorkspaceContext()
const { folder } = useFolderContext() const { activeFolder: folder } = useActiveFolder()
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({}) const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
const turns = useMemo( const turns = useMemo(
-977
View File
@@ -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<GitBranchList>({
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<ConfirmAction | null>(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<GitConflictInfo | null>(null)
const taskSeq = useRef(0)
const worktreeBranchSet = useMemo(
() => new Set(branchList.worktree_branches),
[branchList.worktree_branches]
)
const groupedRemoteBranches = useMemo(() => {
const groups: Record<string, string[]> = {}
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<GitCommitSucceededEventPayload>(
"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<GitPushSucceededEventPayload>(
"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<T>(
label: string,
action: () => Promise<T>,
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 (
<div
key={b}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm opacity-50 select-none"
>
<BranchIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{label}</span>
<span className="ml-auto text-xs">{t("current")}</span>
</div>
)
}
return (
<DropdownMenuSub key={b}>
<DropdownMenuSubTrigger
className="hover:bg-accent hover:text-accent-foreground"
disabled={loading}
>
<BranchIcon className="h-3.5 w-3.5" />
{label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() => {
if (isRemote) {
handleCheckoutRemote(b)
} else {
handleCheckout(b)
}
}}
>
<GitBranch className="h-3.5 w-3.5" />
{t("switchToBranch")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({ type: "merge", branchName: b })
}}
>
<GitMerge className="h-3.5 w-3.5" />
{t("mergeBranchIntoCurrent", {
branchName: b,
currentBranch: branch ?? "-",
})}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({ type: "rebase", branchName: b })
}}
>
<GitPullRequestArrow className="h-3.5 w-3.5" />
{t("rebaseCurrentToBranch", {
currentBranch: branch ?? "-",
branchName: b,
})}
</DropdownMenuItem>
{!isTrackingCurrent && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({
type: isRemote ? "deleteRemote" : "delete",
branchName: b,
})
}}
>
<Trash2 className="h-3.5 w-3.5" />
{t("deleteBranch")}
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
if (branch === null) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-sm tracking-tight hover:text-foreground/80 transition-colors outline-none cursor-default">
<GitFork className="h-3 w-3 shrink-0" />
<span className="truncate">{t("versionControl")}</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-64" align="start">
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(t("tasks.initGitRepo"), () => gitInit(folderPath))
}
>
<GitBranch className="h-3.5 w-3.5" />
{t("initGitRepo")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={handleDropdownOpenChange}>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-sm tracking-tight hover:text-foreground/80 transition-colors outline-none cursor-default">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="truncate">{branch}</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-64" align="start">
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
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,
})
}
)
}
>
<ArrowDownToLine className="h-3.5 w-3.5" />
{t("pullCode")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(t("tasks.fetchInfo"), () =>
withCredentialRetry((creds) => gitFetch(folderPath, creds), {
folderPath,
})
)
}
>
<RefreshCw className="h-3.5 w-3.5" />
{t("fetchRemoteBranches")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
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 })
})
}}
>
<GitCommitHorizontal className="h-3.5 w-3.5" />
{t("openCommitWindow")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
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 })
})
}}
>
<Upload className="h-3.5 w-3.5" />
{t("pushCode")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
setNewBranchName("")
setNewBranchOpen(true)
}}
>
<GitBranchPlus className="h-3.5 w-3.5" />
{t("newBranch")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={handleOpenWorktreeDialog}
>
<FolderGit2 className="h-3.5 w-3.5" />
{t("newWorktree")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
setDropdownOpen(false)
setStashDialogOpen(true)
}}
>
<Archive className="h-3.5 w-3.5" />
{t("stashChanges")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
if (!folder) return
openStashWindow(folder.id).catch((err) => {
const msg = toErrorMessage(err)
pushAlert("error", t("stashPop"), msg)
})
}}
>
<ArchiveRestore className="h-3.5 w-3.5" />
{t("stashPop")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
setDropdownOpen(false)
setManageRemotesOpen(true)
}}
>
<Globe className="h-3.5 w-3.5" />
{t("manageRemotes")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
{branchLoading ? (
<div className="flex items-center justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<ScrollArea className="max-h-64">
<Collapsible open={localOpen} onOpenChange={setLocalOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
{t("localBranches", { count: branchList.local.length })}
</CollapsibleTrigger>
<CollapsibleContent>
{branchList.local.length === 0 ? (
<DropdownMenuItem disabled>
{t("noLocalBranches")}
</DropdownMenuItem>
) : (
branchList.local.map((b) => renderBranchItem(b, false))
)}
</CollapsibleContent>
</Collapsible>
<Collapsible open={remoteOpen} onOpenChange={setRemoteOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
{t("remoteBranches", { count: branchList.remote.length })}
</CollapsibleTrigger>
<CollapsibleContent>
{branchList.remote.length === 0 ? (
<DropdownMenuItem disabled>
{t("noRemoteBranches")}
</DropdownMenuItem>
) : hasMultipleRemotes ? (
remoteNames.map((remoteName) => (
<Collapsible key={remoteName}>
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 pl-6 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
<ChevronRight className="h-3 w-3 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
{remoteName} (
{groupedRemoteBranches[remoteName].length})
</CollapsibleTrigger>
<CollapsibleContent className="pl-3">
{groupedRemoteBranches[remoteName].map((b) =>
renderBranchItem(
b,
true,
b.substring(remoteName.length + 1)
)
)}
</CollapsibleContent>
</Collapsible>
))
) : (
branchList.remote.map((b) => {
const slashIndex = b.indexOf("/")
const shortName =
slashIndex > 0 ? b.substring(slashIndex + 1) : b
return renderBranchItem(b, true, shortName)
})
)}
</CollapsibleContent>
</Collapsible>
</ScrollArea>
)}
</DropdownMenuContent>
</DropdownMenu>
{parentBranch && (
<button
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-orange-500 dark:text-orange-400 hover:bg-accent hover:text-orange-600 dark:hover:text-orange-300 transition-colors cursor-default select-none"
disabled={loading}
onClick={handleMergeParent}
title={t("parentBranchHint", { parentBranch })}
>
<ArrowLeftRight className="h-3 w-3 shrink-0" />
<span className="truncate max-w-32">{parentBranch}</span>
</button>
)}
<AlertDialog
open={confirmAction !== null}
onOpenChange={(open) => {
if (!open) setConfirmAction(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
<AlertDialogDescription>
{getConfirmDescription()}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
variant={
confirmAction?.type === "delete" ||
confirmAction?.type === "forceDelete" ||
confirmAction?.type === "deleteRemote"
? "destructive"
: "default"
}
onClick={handleConfirm}
>
{tCommon("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={newBranchOpen} onOpenChange={setNewBranchOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("dialogs.newBranchTitle")}</DialogTitle>
<DialogDescription>
{t("dialogs.newBranchDescription", { branch: branch ?? "-" })}
</DialogDescription>
</DialogHeader>
<Input
placeholder={t("dialogs.branchNamePlaceholder")}
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.key === "Process") return
if (e.key === "Enter") handleNewBranch()
}}
autoFocus
/>
<DialogFooter>
<Button variant="outline" onClick={() => setNewBranchOpen(false)}>
{tCommon("cancel")}
</Button>
<Button
disabled={!newBranchName.trim() || loading}
onClick={handleNewBranch}
>
{tCommon("create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={worktreeOpen} onOpenChange={setWorktreeOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("dialogs.newWorktreeTitle")}</DialogTitle>
<DialogDescription>
{t("dialogs.newWorktreeDescription", { branch: branch ?? "-" })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="wt-branch">{t("dialogs.branchNameLabel")}</Label>
<Input
id="wt-branch"
placeholder={t("dialogs.branchNamePlaceholder")}
value={worktreeBranchName}
onChange={(e) => handleWorktreeBranchChange(e.target.value)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.key === "Process") return
if (e.key === "Enter") handleNewWorktree()
}}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="wt-path">{t("dialogs.worktreePathLabel")}</Label>
<div className="flex gap-2">
<Input
id="wt-path"
placeholder={t("dialogs.worktreePathPlaceholder")}
value={worktreePath}
onChange={(e) => setWorktreePath(e.target.value)}
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={handleBrowseWorktreePath}
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setWorktreeOpen(false)}>
{tCommon("cancel")}
</Button>
<Button
disabled={
!worktreeBranchName.trim() || !worktreePath.trim() || loading
}
onClick={handleNewWorktree}
>
{tCommon("create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<RemoteManageDialog
open={manageRemotesOpen}
onOpenChange={setManageRemotesOpen}
folderPath={folderPath}
onSaved={() => loadAllBranches()}
/>
<ConflictDialog
conflictInfo={conflictInfo}
folderId={folder?.id ?? 0}
folderPath={folderPath}
onClose={() => setConflictInfo(null)}
onResolved={onBranchChange}
/>
<StashDialog
open={stashDialogOpen}
folderPath={folderPath}
onClose={() => setStashDialogOpen(false)}
onStashed={onBranchChange}
/>
</>
)
}
+2 -2
View File
@@ -11,7 +11,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } 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 { useTerminalContext } from "@/contexts/terminal-context"
import { import {
bootstrapFolderCommandsFromPackageJson, bootstrapFolderCommandsFromPackageJson,
@@ -40,7 +40,7 @@ function setSelectedCommandId(folderId: number, cmdId: number) {
export function CommandDropdown() { export function CommandDropdown() {
const t = useTranslations("Folder.commandDropdown") const t = useTranslations("Folder.commandDropdown")
const { folder } = useFolderContext() const { activeFolder: folder } = useActiveFolder()
const { const {
createTerminalWithCommand, createTerminalWithCommand,
exitedTerminals, exitedTerminals,
@@ -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<FolderHistoryEntry[]>([])
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
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 (
<>
<DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
suppressHydrationWarning
className="flex items-center gap-1 text-sm tracking-tight truncate hover:text-foreground/80 transition-colors outline-none cursor-default"
>
{folderName}
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-64" align="start">
<DropdownMenuItem onSelect={handleOpenFolder}>
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
{t("openFolder")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setCloneOpen(true)}>
<GitBranch className="h-3.5 w-3.5 shrink-0" />
{t("cloneRepository")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openProjectBootWindow()}>
<Rocket className="h-3.5 w-3.5 shrink-0" />
{t("projectBoot")}
</DropdownMenuItem>
{openFolders.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("opened")}</DropdownMenuLabel>
{openFolders.map((entry) => (
<DropdownMenuItem
key={entry.path}
onSelect={() => focusFolderWindow(entry.id)}
>
{entry.path === folderPath ? (
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
) : (
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<div
className={`truncate ${entry.path === folderPath ? "font-medium text-foreground" : ""}`}
>
{entry.name}
</div>
<div className="text-[10px] text-muted-foreground truncate">
{entry.path}
</div>
</div>
</DropdownMenuItem>
))}
</>
)}
{history.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("recentOpen")}</DropdownMenuLabel>
{history.map((entry) => (
<DropdownMenuItem
key={entry.path}
onSelect={() => handleSelect(entry.path)}
>
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="truncate">{entry.name}</div>
<div className="text-[10px] text-muted-foreground truncate">
{entry.path}
</div>
</div>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
<DirectoryBrowserDialog
open={browserOpen}
onOpenChange={setBrowserOpen}
onSelect={(path) => {
openFolderWindow(path, { newWindow: true }).catch((err) => {
console.error("[FolderNameDropdown] failed to open folder:", err)
})
}}
/>
</>
)
}
+11 -97
View File
@@ -20,9 +20,10 @@ import {
SquareTerminal, SquareTerminal,
} from "lucide-react" } from "lucide-react"
import { useTranslations } from "next-intl" 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 { isDesktop, openFileDialog } from "@/lib/platform"
import { useFolderContext } from "@/contexts/folder-context"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useSidebarContext } from "@/contexts/sidebar-context" import { useSidebarContext } from "@/contexts/sidebar-context"
import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useAuxPanelContext } from "@/contexts/aux-panel-context"
@@ -36,8 +37,6 @@ import {
matchShortcutEvent, matchShortcutEvent,
} from "@/lib/keyboard-shortcuts" } from "@/lib/keyboard-shortcuts"
import { AppTitleBar } from "./app-title-bar" import { AppTitleBar } from "./app-title-bar"
import { FolderNameDropdown } from "./folder-name-dropdown"
import { BranchDropdown } from "./branch-dropdown"
import { CommandDropdown } from "./command-dropdown" import { CommandDropdown } from "./command-dropdown"
import { SearchCommandDialog } from "@/components/conversations/search-command-dialog" import { SearchCommandDialog } from "@/components/conversations/search-command-dialog"
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog" import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
@@ -71,7 +70,8 @@ const MODE_TABS = [
export function FolderTitleBar() { export function FolderTitleBar() {
const tModes = useTranslations("Folder.modes") const tModes = useTranslations("Folder.modes")
const tTitleBar = useTranslations("Folder.folderTitleBar") const tTitleBar = useTranslations("Folder.folderTitleBar")
const { folder } = useFolderContext() const { openFolder } = useAppWorkspace()
const { activeFolder } = useActiveFolder()
const { isOpen, toggle } = useSidebarContext() const { isOpen, toggle } = useSidebarContext()
const { isOpen: auxPanelOpen, toggle: toggleAuxPanel } = useAuxPanelContext() const { isOpen: auxPanelOpen, toggle: toggleAuxPanel } = useAuxPanelContext()
const { isOpen: terminalOpen, toggle: toggleTerminal } = useTerminalContext() const { isOpen: terminalOpen, toggle: toggleTerminal } = useTerminalContext()
@@ -79,14 +79,8 @@ export function FolderTitleBar() {
const { mode, setMode } = useWorkspaceContext() const { mode, setMode } = useWorkspaceContext()
const isMac = useIsMac() const isMac = useIsMac()
const { shortcuts } = useShortcutSettings() const { shortcuts } = useShortcutSettings()
const [branch, setBranch] = useState<string | null>(null)
const [searchOpen, setSearchOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false) const [browserOpen, setBrowserOpen] = useState(false)
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined
)
const folderPath = folder?.path ?? ""
const handleOpenFolder = useCallback(async () => { const handleOpenFolder = useCallback(async () => {
if (isDesktop()) { if (isDesktop()) {
@@ -97,14 +91,14 @@ export function FolderTitleBar() {
}) })
if (!result) return if (!result) return
const selected = Array.isArray(result) ? result[0] : result const selected = Array.isArray(result) ? result[0] : result
await openFolderWindow(selected, { newWindow: true }) await openFolder(selected)
} catch (err) { } catch (err) {
console.error("[FolderTitleBar] failed to open folder:", err) console.error("[FolderTitleBar] failed to open folder:", err)
} }
} else { } else {
setBrowserOpen(true) setBrowserOpen(true)
} }
}, []) }, [openFolder])
const handleOpenSettings = useCallback(() => { const handleOpenSettings = useCallback(() => {
openSettingsWindow().catch((err) => { 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(() => { useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
if (matchShortcutEvent(e, shortcuts.toggle_search)) { if (matchShortcutEvent(e, shortcuts.toggle_search)) {
@@ -192,9 +129,9 @@ export function FolderTitleBar() {
return return
} }
if (matchShortcutEvent(e, shortcuts.new_conversation)) { if (matchShortcutEvent(e, shortcuts.new_conversation)) {
if (!folderPath) return if (!activeFolder) return
e.preventDefault() e.preventDefault()
openNewConversationTab(folderPath) openNewConversationTab(activeFolder.id, activeFolder.path)
return return
} }
if (matchShortcutEvent(e, shortcuts.open_folder)) { if (matchShortcutEvent(e, shortcuts.open_folder)) {
@@ -210,7 +147,7 @@ export function FolderTitleBar() {
document.addEventListener("keydown", handleKeyDown) document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown) return () => document.removeEventListener("keydown", handleKeyDown)
}, [ }, [
folderPath, activeFolder,
handleOpenFolder, handleOpenFolder,
handleOpenSettings, handleOpenSettings,
openNewConversationTab, openNewConversationTab,
@@ -220,14 +157,6 @@ export function FolderTitleBar() {
toggleTerminal, toggleTerminal,
]) ])
const refreshBranch = useCallback(async () => {
if (!folderPath) return
try {
setBranch(await getGitBranch(folderPath))
} catch {
setBranch(null)
}
}, [folderPath])
const isMobile = useIsMobile() const isMobile = useIsMobile()
const modeContainerRef = useRef<HTMLDivElement>(null) const modeContainerRef = useRef<HTMLDivElement>(null)
const modeItemRefs = useRef<Map<string, HTMLDivElement>>(new Map()) const modeItemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
@@ -326,7 +255,6 @@ export function FolderTitleBar() {
className="block h-3 w-3 shrink-0" className="block h-3 w-3 shrink-0"
shapeRendering="geometricPrecision" shapeRendering="geometricPrecision"
/> />
{/* Hide text labels on mobile to save space */}
{!isMobile && ( {!isMobile && (
<span <span
className={cn( className={cn(
@@ -365,23 +293,9 @@ export function FolderTitleBar() {
> >
<Menu className="h-4 w-4" /> <Menu className="h-4 w-4" />
</Button> </Button>
<FolderNameDropdown />
<BranchDropdown
branch={branch}
parentBranch={folder?.parent_branch ?? null}
onBranchChange={refreshBranch}
/>
</div> </div>
) : ( ) : (
<div className="flex min-w-0 items-center gap-4">
<FolderNameDropdown />
<BranchDropdown
branch={branch}
parentBranch={folder?.parent_branch ?? null}
onBranchChange={refreshBranch}
/>
<div data-tauri-drag-region className="h-8 flex-1" /> <div data-tauri-drag-region className="h-8 flex-1" />
</div>
) )
} }
center={isMobile ? undefined : modeTabsElement} center={isMobile ? undefined : modeTabsElement}
@@ -511,7 +425,7 @@ export function FolderTitleBar() {
open={browserOpen} open={browserOpen}
onOpenChange={setBrowserOpen} onOpenChange={setBrowserOpen}
onSelect={(path) => { onSelect={(path) => {
openFolderWindow(path, { newWindow: true }).catch((err) => { openFolder(path).catch((err) => {
console.error("[FolderTitleBar] failed to open folder:", err) console.error("[FolderTitleBar] failed to open folder:", err)
}) })
}} }}
+173 -10
View File
@@ -1,9 +1,21 @@
"use client" "use client"
import { useCallback, useRef } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { ChevronsDownUp, ChevronsUpDown, Crosshair, Plus } from "lucide-react" import {
ChevronsDownUp,
ChevronsUpDown,
Crosshair,
FolderPlus,
FolderTree,
Plus,
Rows3,
Search,
X,
} from "lucide-react"
import { useTranslations } from "next-intl" 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 { useTabContext } from "@/contexts/tab-context"
import { useSidebarContext } from "@/contexts/sidebar-context" import { useSidebarContext } from "@/contexts/sidebar-context"
import { import {
@@ -11,27 +23,172 @@ import {
type SidebarConversationListHandle, type SidebarConversationListHandle,
} from "@/components/conversations/sidebar-conversation-list" } from "@/components/conversations/sidebar-conversation-list"
import { Button } from "@/components/ui/button" 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 { 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() { export function Sidebar() {
const t = useTranslations("Folder.sidebar") const t = useTranslations("Folder.sidebar")
const { folder } = useFolderContext() const { activeFolder } = useActiveFolder()
const { allFolders, conversations, openFolder } = useAppWorkspace()
const { openNewConversationTab } = useTabContext() const { openNewConversationTab } = useTabContext()
const { isOpen, toggle } = useSidebarContext() const { isOpen, toggle } = useSidebarContext()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const listRef = useRef<SidebarConversationListHandle>(null) const listRef = useRef<SidebarConversationListHandle>(null)
const [viewMode, setViewMode] = useState<SidebarViewMode>("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(() => { const handleNewConversation = useCallback(() => {
if (!folder) return if (!activeFolder) return
openNewConversationTab(folder.path) openNewConversationTab(activeFolder.id, activeFolder.path)
}, [folder, openNewConversationTab]) }, [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 if (!isOpen) return null
return ( return (
<aside className="group/sidebar flex h-full min-h-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground select-none"> <aside className="group/sidebar flex h-full min-h-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground select-none">
<div className="flex h-10 items-center justify-between border-b border-border px-4"> <TooltipProvider>
<h2 className="text-xs font-bold">{t("title")}</h2> <div className="flex items-center justify-between border-b border-border px-3 py-2 gap-2">
<div className="flex items-center gap-2 min-w-0 text-[11px] text-muted-foreground tabular-nums">
<span className="truncate">
{t("statsLabel", {
folders: allFolders.length,
convos: conversations.length,
})}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={handleOpenFolder}
>
<FolderPlus className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("openFolder")}</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground pointer-events-none" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("searchPlaceholder")}
className="h-7 pl-6 pr-6 text-xs"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground rounded-sm p-0.5"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 shrink-0 text-muted-foreground",
viewMode === "flat" && "bg-accent text-foreground"
)}
onClick={() => handleSetViewMode("flat")}
>
<Rows3 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("viewFlat")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 shrink-0 text-muted-foreground",
viewMode === "grouped" && "bg-accent text-foreground"
)}
onClick={() => handleSetViewMode("grouped")}
>
<FolderTree className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("viewGrouped")}</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center justify-between border-b border-border px-2 h-7">
<h2 className="text-xs font-bold text-muted-foreground truncate">
{t("title")}
</h2>
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover/sidebar:opacity-100"> <div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover/sidebar:opacity-100">
<Button <Button
variant="ghost" variant="ghost"
@@ -65,12 +222,14 @@ export function Sidebar() {
size="icon" size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground" className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={handleNewConversation} onClick={handleNewConversation}
disabled={!activeFolder}
title={t("newConversation")} title={t("newConversation")}
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
</div> </div>
</TooltipProvider>
{/* On mobile, clicking a conversation card auto-closes the Sheet */} {/* On mobile, clicking a conversation card auto-closes the Sheet */}
<div <div
@@ -86,7 +245,11 @@ export function Sidebar() {
: undefined : undefined
} }
> >
<SidebarConversationList ref={listRef} /> <SidebarConversationList
ref={listRef}
viewMode={viewMode}
searchQuery={searchQuery}
/>
</div> </div>
</aside> </aside>
) )
@@ -5,7 +5,7 @@ import { Unplug } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { useConnectionStore } from "@/contexts/acp-connections-context" import { useConnectionStore } from "@/contexts/acp-connections-context"
import { useTabContext } from "@/contexts/tab-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 { AgentIcon } from "@/components/agent-icon"
import { import {
Tooltip, Tooltip,
@@ -42,7 +42,7 @@ export function StatusBarConnection() {
const t = useTranslations("Folder.statusBar.connection") const t = useTranslations("Folder.statusBar.connection")
const store = useConnectionStore() const store = useConnectionStore()
const { tabs, activeTabId } = useTabContext() const { tabs, activeTabId } = useTabContext()
const { conversations } = useFolderContext() const { conversations } = useAppWorkspace()
// Subscribe to activeKey changes // Subscribe to activeKey changes
const subscribeActiveKey = useCallback( const subscribeActiveKey = useCallback(
@@ -3,11 +3,11 @@
import { useMemo } from "react" import { useMemo } from "react"
import { GitBranch } from "lucide-react" import { GitBranch } from "lucide-react"
import { useTabContext } from "@/contexts/tab-context" import { useTabContext } from "@/contexts/tab-context"
import { useFolderContext } from "@/contexts/folder-context" import { useAppWorkspace } from "@/contexts/app-workspace-context"
export function StatusBarSessionInfo() { export function StatusBarSessionInfo() {
const { tabs, activeTabId } = useTabContext() const { tabs, activeTabId } = useTabContext()
const { conversations } = useFolderContext() const { conversations } = useAppWorkspace()
const activeTab = useMemo( const activeTab = useMemo(
() => tabs.find((t) => t.id === activeTabId) ?? null, () => tabs.find((t) => t.id === activeTabId) ?? null,
+2 -2
View File
@@ -3,7 +3,7 @@
import { useMemo } from "react" import { useMemo } from "react"
import { BarChart3 } from "lucide-react" import { BarChart3 } from "lucide-react"
import { useTranslations } from "next-intl" 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 { AGENT_LABELS } from "@/lib/types"
import { AgentIcon } from "@/components/agent-icon" import { AgentIcon } from "@/components/agent-icon"
import { import {
@@ -14,7 +14,7 @@ import {
export function StatusBarStats() { export function StatusBarStats() {
const t = useTranslations("Folder.statusBar.stats") const t = useTranslations("Folder.statusBar.stats")
const { stats } = useFolderContext() const { stats } = useAppWorkspace()
const activeAgents = useMemo( const activeAgents = useMemo(
() => stats?.by_agent.filter((a) => a.conversation_count > 0) ?? [], () => stats?.by_agent.filter((a) => a.conversation_count > 0) ?? [],
@@ -37,7 +37,7 @@ import {
import { isDesktop, openFileDialog } from "@/lib/platform" import { isDesktop, openFileDialog } from "@/lib/platform"
import { import {
createShadcnProject, createShadcnProject,
openFolderWindow, openFolder,
detectPackageManager, detectPackageManager,
} from "@/lib/api" } from "@/lib/api"
import { extractAppCommandError, toErrorMessage } from "@/lib/app-error" import { extractAppCommandError, toErrorMessage } from "@/lib/app-error"
@@ -122,7 +122,7 @@ export function CreateProjectDialog({
toast.success(t("toasts.createSuccess")) toast.success(t("toasts.createSuccess"))
onOpenChange(false) onOpenChange(false)
resetForm() resetForm()
await openFolderWindow(projectPath) await openFolder(projectPath)
} catch (err) { } catch (err) {
const appErr = extractAppCommandError(err) const appErr = extractAppCommandError(err)
const message = const message =
+2 -2
View File
@@ -44,7 +44,7 @@ import {
expertsOpenCentralDir, expertsOpenCentralDir,
expertsReadContent, expertsReadContent,
expertsUnlinkFromAgent, expertsUnlinkFromAgent,
openFolderWindow, openFolder,
} from "@/lib/api" } from "@/lib/api"
import { invalidateAgentExpertsCache } from "@/hooks/use-agent-experts" import { invalidateAgentExpertsCache } from "@/hooks/use-agent-experts"
import type { import type {
@@ -369,7 +369,7 @@ export function ExpertsSettings() {
const handleOpenCentralDir = useCallback(async () => { const handleOpenCentralDir = useCallback(async () => {
try { try {
const path = await expertsOpenCentralDir() const path = await expertsOpenCentralDir()
await openFolderWindow(path) await openFolder(path)
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.openFolderFailed"), { description: message }) toast.error(t("toasts.openFolderFailed"), { description: message })
+2 -2
View File
@@ -53,7 +53,7 @@ import {
acpListAgents, acpListAgents,
acpListAgentSkills, acpListAgentSkills,
loadFolderHistory, loadFolderHistory,
openFolderWindow, openFolder,
acpReadAgentSkill, acpReadAgentSkill,
acpSaveAgentSkill, acpSaveAgentSkill,
} from "@/lib/api" } from "@/lib/api"
@@ -475,7 +475,7 @@ export function SkillsSettings() {
async (skill: AgentSkillItem) => { async (skill: AgentSkillItem) => {
const dirPath = skillDirectoryPath(skill) const dirPath = skillDirectoryPath(skill)
try { try {
await openFolderWindow(dirPath) await openFolder(dirPath)
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.openFolderFailed"), { description: message }) toast.error(t("toasts.openFolderFailed"), { description: message })
+25 -3
View File
@@ -1,7 +1,8 @@
"use client" "use client"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Reorder } from "motion/react" import { Reorder } from "motion/react"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { useTabContext } from "@/contexts/tab-context" import { useTabContext } from "@/contexts/tab-context"
import { useWorkspaceContext } from "@/contexts/workspace-context" import { useWorkspaceContext } from "@/contexts/workspace-context"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
@@ -18,11 +19,25 @@ export function TabBar() {
closeTab, closeTab,
closeOtherTabs, closeOtherTabs,
closeAllTabs, closeAllTabs,
closeTabsByFolder,
pinTab, pinTab,
toggleTileMode, toggleTileMode,
reorderTabs, reorderTabs,
} = useTabContext() } = useTabContext()
const { folders, branches } = useAppWorkspace()
const { mode, activePane } = useWorkspaceContext() const { mode, activePane } = useWorkspaceContext()
const folderIndex = useMemo(() => {
const map = new Map<number, { name: string }>()
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 { shortcuts } = useShortcutSettings()
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
@@ -86,20 +101,27 @@ export function TabBar() {
: ["pb-1.5", "[&::-webkit-scrollbar]:h-0"] : ["pb-1.5", "[&::-webkit-scrollbar]:h-0"]
)} )}
> >
{tabs.map((tab) => ( {tabs.map((tab) => {
const folderInfo = folderIndex.get(tab.folderId)
return (
<TabItem <TabItem
key={tab.id} key={tab.id}
tab={tab} tab={tab}
isActive={tab.id === activeTabId} isActive={tab.id === activeTabId}
isTileMode={isTileMode} isTileMode={isTileMode}
folderName={folderInfo?.name ?? null}
folderBranch={branches.get(tab.folderId) ?? null}
onSwitch={switchTab} onSwitch={switchTab}
onClose={closeTab} onClose={closeTab}
onCloseOthers={closeOtherTabs} onCloseOthers={closeOtherTabs}
onCloseAll={closeAllTabs} onCloseAll={closeAllTabs}
onCloseFolderTabs={closeTabsByFolder}
onRevealInSidebar={handleRevealInSidebar}
onPin={pinTab} onPin={pinTab}
onToggleTile={toggleTileMode} onToggleTile={toggleTileMode}
/> />
))} )
})}
</Reorder.Group> </Reorder.Group>
) )
} }
+28 -1
View File
@@ -20,10 +20,14 @@ interface TabItemProps {
tab: TabItemData tab: TabItemData
isActive: boolean isActive: boolean
isTileMode: boolean isTileMode: boolean
folderName: string | null
folderBranch: string | null
onSwitch: (tabId: string) => void onSwitch: (tabId: string) => void
onClose: (tabId: string) => void onClose: (tabId: string) => void
onCloseOthers: (tabId: string) => void onCloseOthers: (tabId: string) => void
onCloseAll: () => void onCloseAll: () => void
onCloseFolderTabs: (folderId: number) => void
onRevealInSidebar: (folderId: number) => void
onPin: (tabId: string) => void onPin: (tabId: string) => void
onToggleTile: () => void onToggleTile: () => void
} }
@@ -32,10 +36,14 @@ export const TabItem = memo(function TabItem({
tab, tab,
isActive, isActive,
isTileMode, isTileMode,
folderName,
folderBranch,
onSwitch, onSwitch,
onClose, onClose,
onCloseOthers, onCloseOthers,
onCloseAll, onCloseAll,
onCloseFolderTabs,
onRevealInSidebar,
onPin, onPin,
onToggleTile, onToggleTile,
}: TabItemProps) { }: TabItemProps) {
@@ -43,6 +51,19 @@ export const TabItem = memo(function TabItem({
const isDragging = useRef(false) const isDragging = useRef(false)
const itemRef = useRef<HTMLDivElement>(null) const itemRef = useRef<HTMLDivElement>(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 clearResidualStyles = useCallback(() => {
const el = itemRef.current const el = itemRef.current
if (!el) return if (!el) return
@@ -119,7 +140,7 @@ export const TabItem = memo(function TabItem({
"truncate max-w-[140px]", "truncate max-w-[140px]",
!tab.isPinned && "[font-style:oblique]" !tab.isPinned && "[font-style:oblique]"
)} )}
title={tab.title} title={tooltip}
> >
{tab.title} {tab.title}
</span> </span>
@@ -146,7 +167,13 @@ export const TabItem = memo(function TabItem({
<ContextMenuItem onSelect={handleCloseOthers}> <ContextMenuItem onSelect={handleCloseOthers}>
{t("closeOthers")} {t("closeOthers")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem onSelect={handleCloseFolderTabs}>
{t("closeFolderTabs", { folder: resolvedFolderName })}
</ContextMenuItem>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem onSelect={handleRevealInSidebar}>
{t("revealInSidebar")}
</ContextMenuItem>
<ContextMenuItem onSelect={onToggleTile}> <ContextMenuItem onSelect={onToggleTile}>
{isTileMode ? t("untileDisplay") : t("tileDisplay")} {isTileMode ? t("untileDisplay") : t("tileDisplay")}
</ContextMenuItem> </ContextMenuItem>
+39 -1
View File
@@ -1,8 +1,10 @@
"use client" "use client"
import { useRef, useState } from "react" import { useMemo, useRef, useState } from "react"
import { Minus, Plus, X } from "lucide-react" import { Minus, Plus, X } from "lucide-react"
import { useTranslations } from "next-intl" 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 { useTerminalContext } from "@/contexts/terminal-context"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import { useIsMac } from "@/hooks/use-is-mac" import { useIsMac } from "@/hooks/use-is-mac"
@@ -15,6 +17,13 @@ import {
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu" } from "@/components/ui/context-menu"
import { FolderBadge } from "@/components/ui/folder-badge"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
export function TerminalTabBar() { export function TerminalTabBar() {
const t = useTranslations("Folder.terminal") const t = useTranslations("Folder.terminal")
@@ -31,6 +40,16 @@ export function TerminalTabBar() {
createTerminal, createTerminal,
toggle, toggle,
} = useTerminalContext() } = useTerminalContext()
const { activeFolderId } = useActiveFolder()
const { folders } = useAppWorkspace()
const folderIndex = useMemo(() => {
const map = new Map<number, string>()
for (const f of folders) map.set(f.id, f.name)
return map
}, [folders])
const canCreateTerminal = activeFolderId != null
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState("") const [editValue, setEditValue] = useState("")
@@ -62,6 +81,13 @@ export function TerminalTabBar() {
}`} }`}
onClick={() => switchTerminal(tab.id)} onClick={() => switchTerminal(tab.id)}
> >
<FolderBadge
folderId={tab.folderId}
folderName={
folderIndex.get(tab.folderId) ?? String(tab.folderId)
}
size="sm"
/>
{editingId === tab.id ? ( {editingId === tab.id ? (
<input <input
ref={inputRef} ref={inputRef}
@@ -108,14 +134,26 @@ export function TerminalTabBar() {
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
))} ))}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 shrink-0" className="h-6 w-6 shrink-0"
onClick={() => createTerminal()} onClick={() => createTerminal()}
disabled={!canCreateTerminal}
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
</Button> </Button>
</span>
</TooltipTrigger>
{!canCreateTerminal && (
<TooltipContent side="top">{t("openFolderFirst")}</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
+33
View File
@@ -0,0 +1,33 @@
import { memo } from "react"
import { folderBadgeColor, folderBadgeLabel } from "@/lib/folder-badge"
import { cn } from "@/lib/utils"
interface FolderBadgeProps {
folderId: number
folderName: string
/** Size variant: sm ~= tab bar (14px), md ~= sidebar card (16px). */
size?: "sm" | "md"
className?: string
}
export const FolderBadge = memo(function FolderBadge({
folderId,
folderName,
size = "sm",
className,
}: FolderBadgeProps) {
return (
<span
aria-label={folderName}
title={folderName}
className={cn(
"inline-flex items-center justify-center shrink-0 rounded text-white font-medium",
size === "sm" ? "w-3.5 h-3.5 text-[9px]" : "w-4 h-4 text-[10px]",
folderBadgeColor(folderId),
className
)}
>
{folderBadgeLabel(folderName)}
</span>
)
})
-114
View File
@@ -1,114 +0,0 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { Settings } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { loadFolderHistory, openSettingsWindow } from "@/lib/api"
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import type { FolderHistoryEntry } from "@/lib/types"
import { FolderList } from "@/components/welcome/folder-list"
import { FolderActions } from "@/components/welcome/folder-actions"
import { SoftwareInfo } from "@/components/welcome/software-info"
import { Button } from "@/components/ui/button"
import { AppToaster } from "@/components/ui/app-toaster"
import { resolveWelcomeError } from "@/components/welcome/error-utils"
import { AppTitleBar } from "@/components/layout/app-title-bar"
import { useIsMobile } from "@/hooks/use-mobile"
export function WelcomeScreen() {
const t = useTranslations("WelcomePage")
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const isMobile = useIsMobile()
const { shortcuts } = useShortcutSettings()
const handleOpenSettings = useCallback(() => {
openSettingsWindow().catch((err) => {
console.error("[WelcomeScreen] failed to open settings:", err)
const resolvedError = resolveWelcomeError(err)
toast.error(t("toasts.openSettingsFailed"), {
description: resolvedError.detail ?? t(resolvedError.key),
})
})
}, [t])
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (matchShortcutEvent(e, shortcuts.open_settings)) {
e.preventDefault()
handleOpenSettings()
}
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [shortcuts, handleOpenSettings])
const refreshHistory = useCallback(async () => {
try {
setHistory(await loadFolderHistory())
} catch (err) {
console.error("[WelcomeScreen] failed to load folder history:", err)
const resolvedError = resolveWelcomeError(err)
toast.error(t("toasts.loadFolderHistoryFailed"), {
description: resolvedError.detail ?? t(resolvedError.key),
})
setHistory([])
} finally {
setLoading(false)
}
}, [t])
useEffect(() => {
refreshHistory()
}, [refreshHistory])
return (
<div className="h-screen flex flex-col overflow-hidden bg-background text-foreground">
<AppTitleBar
center={
<span className="text-sm font-bold tracking-tight">{t("title")}</span>
}
right={
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-foreground/80"
onClick={handleOpenSettings}
title={t("openSettings")}
aria-label={t("openSettings")}
type="button"
>
<Settings className="h-3.5 w-3.5" />
</Button>
}
/>
<div
className={
isMobile
? "flex-1 flex flex-col overflow-hidden"
: "flex-1 flex overflow-hidden"
}
>
<div
className={
isMobile
? "shrink-0 flex flex-col border-b"
: "w-60 shrink-0 flex flex-col border-r"
}
>
<SoftwareInfo />
<FolderActions />
</div>
<FolderList
history={history}
loading={loading}
onRefresh={refreshHistory}
/>
</div>
<AppToaster position="bottom-right" closeButton duration={4000} />
</div>
)
}
@@ -3,8 +3,9 @@
import { useState, useMemo } from "react" import { useState, useMemo } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
import { cloneRepository, openFolderWindow } from "@/lib/api" import { cloneRepository } from "@/lib/api"
import { isDesktop, openFileDialog } from "@/lib/platform" import { isDesktop, openFileDialog } from "@/lib/platform"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { useGitCredential } from "@/contexts/git-credential-context" import { useGitCredential } from "@/contexts/git-credential-context"
import { import {
Dialog, Dialog,
@@ -17,7 +18,7 @@ import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { FolderOpen, Loader2 } from "lucide-react" 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" import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
interface CloneDialogProps { interface CloneDialogProps {
@@ -28,6 +29,7 @@ interface CloneDialogProps {
export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) { export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
const t = useTranslations("WelcomePage") const t = useTranslations("WelcomePage")
const { withCredentialRetry } = useGitCredential() const { withCredentialRetry } = useGitCredential()
const { openFolder } = useAppWorkspace()
const [url, setUrl] = useState("") const [url, setUrl] = useState("")
const [targetDir, setTargetDir] = useState("") const [targetDir, setTargetDir] = useState("")
const [cloning, setCloning] = useState(false) const [cloning, setCloning] = useState(false)
@@ -73,7 +75,7 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
(creds) => cloneRepository(url, fullPath, creds), (creds) => cloneRepository(url, fullPath, creds),
{ remoteUrl: url } { remoteUrl: url }
) )
await openFolderWindow(fullPath) await openFolder(fullPath)
onOpenChange(false) onOpenChange(false)
resetForm() resetForm()
} catch (err) { } catch (err) {
@@ -4,15 +4,17 @@ import { useState } from "react"
import { FolderOpen, GitBranch, Rocket } from "lucide-react" import { FolderOpen, GitBranch, Rocket } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" 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 { isDesktop, openFileDialog } from "@/lib/platform"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { CloneDialog } from "./clone-dialog" 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" import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
export function FolderActions() { export function FolderActions() {
const t = useTranslations("WelcomePage") const t = useTranslations("WelcomePage")
const { openFolder } = useAppWorkspace()
const [cloneOpen, setCloneOpen] = useState(false) const [cloneOpen, setCloneOpen] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false) const [browserOpen, setBrowserOpen] = useState(false)
@@ -25,7 +27,7 @@ export function FolderActions() {
if (!result) return if (!result) return
const selected = Array.isArray(result) ? result[0] : result const selected = Array.isArray(result) ? result[0] : result
try { try {
await openFolderWindow(selected) await openFolder(selected)
} catch (err) { } catch (err) {
console.error("[FolderActions] failed to open folder:", err) console.error("[FolderActions] failed to open folder:", err)
const resolvedError = resolveWelcomeError(err) const resolvedError = resolveWelcomeError(err)
@@ -40,7 +42,7 @@ export function FolderActions() {
const handleBrowserSelect = async (path: string) => { const handleBrowserSelect = async (path: string) => {
try { try {
await openFolderWindow(path) await openFolder(path)
} catch (err) { } catch (err) {
console.error("[FolderActions] failed to open folder:", err) console.error("[FolderActions] failed to open folder:", err)
const resolvedError = resolveWelcomeError(err) const resolvedError = resolveWelcomeError(err)
@@ -6,10 +6,11 @@ import { formatDistanceToNow } from "date-fns"
import { enUS, zhCN, zhTW } from "date-fns/locale" import { enUS, zhCN, zhTW } from "date-fns/locale"
import { useLocale, useTranslations } from "next-intl" import { useLocale, useTranslations } from "next-intl"
import { toast } from "sonner" 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 type { FolderHistoryEntry } from "@/lib/types"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { resolveWelcomeError } from "@/components/welcome/error-utils" import { resolveWelcomeError } from "@/components/workspace-empty/error-utils"
interface FolderListProps { interface FolderListProps {
history: FolderHistoryEntry[] history: FolderHistoryEntry[]
@@ -20,6 +21,7 @@ interface FolderListProps {
export function FolderList({ history, loading, onRefresh }: FolderListProps) { export function FolderList({ history, loading, onRefresh }: FolderListProps) {
const t = useTranslations("WelcomePage") const t = useTranslations("WelcomePage")
const locale = useLocale() const locale = useLocale()
const { openFolder } = useAppWorkspace()
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const dateFnsLocale = const dateFnsLocale =
locale === "zh-CN" ? zhCN : locale === "zh-TW" ? zhTW : enUS 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) => { const handleOpen = async (path: string) => {
try { try {
await openFolderWindow(path) await openFolder(path)
} catch (err) { } catch (err) {
console.error("Failed to open folder:", err) console.error("Failed to open folder:", err)
const resolvedError = resolveWelcomeError(err) const resolvedError = resolveWelcomeError(err)
@@ -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<FolderHistoryEntry[]>([])
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 (
<div className="flex h-full w-full overflow-hidden">
<aside className="flex w-72 shrink-0 flex-col border-r">
<SoftwareInfo />
<FolderActions />
</aside>
<main className="flex-1 min-w-0">
<FolderList history={history} loading={loading} onRefresh={refresh} />
</main>
</div>
)
}
@@ -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
}
+2 -2
View File
@@ -51,7 +51,7 @@ import {
clearStalePrefs, clearStalePrefs,
} from "@/lib/selector-prefs-storage" } from "@/lib/selector-prefs-storage"
import { useAlertContext, type AlertAction } from "@/contexts/alert-context" 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) ── // ── Shared types (re-exported for consumers) ──
@@ -1330,7 +1330,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
const t = useTranslations("Folder.chat.acpConnections") const t = useTranslations("Folder.chat.acpConnections")
const tChat = useTranslations("Folder.chat") const tChat = useTranslations("Folder.chat")
const { pushAlert } = useAlertContext() const { pushAlert } = useAlertContext()
const { folder } = useFolderContext() const { activeFolder: folder } = useActiveFolder()
const folderNameRef = useRef(folder?.name) const folderNameRef = useRef(folder?.name)
useEffect(() => { useEffect(() => {
folderNameRef.current = folder?.name folderNameRef.current = folder?.name
+43
View File
@@ -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<ActiveFolderContextValue | null>(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<ActiveFolderContextValue>(
() => ({ activeFolderId, activeFolder }),
[activeFolderId, activeFolder]
)
return (
<ActiveFolderContext.Provider value={value}>
{children}
</ActiveFolderContext.Provider>
)
}
+383
View File
@@ -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<Pick<DbConversationSummary, "status" | "title">>
) => void
branches: Map<number, string | null>
getBranch: (folderId: number) => string | null | undefined
setBranch: (folderId: number, branch: string | null) => void
openFolder: (path: string) => Promise<FolderDetail>
addFolderToWorkspaceById: (folderId: number) => Promise<FolderDetail>
removeFolderFromWorkspace: (folderId: number) => Promise<void>
refreshFolder: (id: number) => Promise<void>
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<AppWorkspaceContextValue | null>(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<AgentType, number>()
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<FolderDetail[]>([])
const [allFolders, setAllFolders] = useState<FolderDetail[]>([])
const [foldersHydrated, setFoldersHydrated] = useState(false)
const [foldersLoading, setFoldersLoading] = useState(true)
const [conversations, setConversations] = useState<DbConversationSummary[]>(
[]
)
const [conversationsLoading, setConversationsLoading] = useState(true)
const [conversationsError, setConversationsError] = useState<string | null>(
null
)
const [branches, setBranches] = useState<Map<number, string | null>>(
new Map()
)
const [activeFolderId, setActiveFolderId] = useState<number | null>(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<Pick<DbConversationSummary, "status" | "title">>
) => {
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<typeof setTimeout> | 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<AppWorkspaceContextValue>(
() => ({
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 (
<AppWorkspaceContext.Provider value={value}>
{children}
</AppWorkspaceContext.Provider>
)
}
+14 -9
View File
@@ -13,9 +13,12 @@ import {
loadPersistedPanelState, loadPersistedPanelState,
savePersistedPanelState, savePersistedPanelState,
} from "@/lib/panel-state-storage" } from "@/lib/panel-state-storage"
import { useActiveFolder } from "@/contexts/active-folder-context"
export type AuxPanelTab = "file_tree" | "changes" | "git_log" | "session_files" export type AuxPanelTab = "file_tree" | "changes" | "git_log" | "session_files"
const STORAGE_KEY = "workspace:right-sidebar"
const DEFAULT_WIDTH = 320 const DEFAULT_WIDTH = 320
const MIN_WIDTH = 200 const MIN_WIDTH = 200
const MAX_WIDTH = 500 const MAX_WIDTH = 500
@@ -53,17 +56,11 @@ function clampWidth(width: number) {
interface AuxPanelProviderProps { interface AuxPanelProviderProps {
children: ReactNode children: ReactNode
folderId: number
} }
export function AuxPanelProvider({ export function AuxPanelProvider({ children }: AuxPanelProviderProps) {
children, const storageKey = STORAGE_KEY
folderId, const { activeFolderId } = useActiveFolder()
}: AuxPanelProviderProps) {
const storageKey = useMemo(
() => `folder:${folderId}:right-sidebar`,
[folderId]
)
const [isOpen, setIsOpen] = useState(DEFAULT_IS_OPEN) const [isOpen, setIsOpen] = useState(DEFAULT_IS_OPEN)
const [width, setWidthState] = useState(DEFAULT_WIDTH) const [width, setWidthState] = useState(DEFAULT_WIDTH)
const [restored, setRestored] = useState(false) const [restored, setRestored] = useState(false)
@@ -109,6 +106,14 @@ export function AuxPanelProvider({
savePersistedPanelState(storageKey, { isOpen, width }) savePersistedPanelState(storageKey, { isOpen, width })
}, [isOpen, restored, storageKey, 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( const value = useMemo(
() => ({ () => ({
isOpen, isOpen,
-299
View File
@@ -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<Pick<DbConversationSummary, "status" | "title">>
) => void
}
const FolderContext = createContext<FolderContextValue | null>(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<AgentType, number>()
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<string, DbConversationSummary[]>()
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<FolderDetail | null>(null)
const [folderLoading, setFolderLoading] = useState(true)
// Conversations
const cacheKey = String(folderId)
const [conversations, setConversations] = useState<DbConversationSummary[]>(
() => cache.get(cacheKey) ?? []
)
const [loading, setLoading] = useState(() => !cache.has(cacheKey))
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedConversation, setSelectedConversation] =
useState<SelectedConversation | null>(() => {
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<NewConversationState | null>(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<Pick<DbConversationSummary, "status" | "title">>
) => {
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<FolderContextValue>(
() => ({
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 (
<FolderContext.Provider value={value}>{children}</FolderContext.Provider>
)
}
+4 -6
View File
@@ -14,6 +14,8 @@ import {
savePersistedPanelState, savePersistedPanelState,
} from "@/lib/panel-state-storage" } from "@/lib/panel-state-storage"
const STORAGE_KEY = "workspace:left-sidebar"
const DEFAULT_WIDTH = 320 const DEFAULT_WIDTH = 320
const MIN_WIDTH = 200 const MIN_WIDTH = 200
const MAX_WIDTH = 600 const MAX_WIDTH = 600
@@ -45,14 +47,10 @@ function clampWidth(width: number) {
interface SidebarProviderProps { interface SidebarProviderProps {
children: ReactNode children: ReactNode
folderId: number
} }
export function SidebarProvider({ children, folderId }: SidebarProviderProps) { export function SidebarProvider({ children }: SidebarProviderProps) {
const storageKey = useMemo( const storageKey = STORAGE_KEY
() => `folder:${folderId}:left-sidebar`,
[folderId]
)
const [isOpen, setIsOpen] = useState(DEFAULT_IS_OPEN) const [isOpen, setIsOpen] = useState(DEFAULT_IS_OPEN)
const [width, setWidthState] = useState(DEFAULT_WIDTH) const [width, setWidthState] = useState(DEFAULT_WIDTH)
const [restored, setRestored] = useState(false) const [restored, setRestored] = useState(false)
+184 -198
View File
@@ -11,19 +11,16 @@ import {
type ReactNode, type ReactNode,
} from "react" } from "react"
import { useTranslations } from "next-intl" 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 { useWorkspaceContext } from "@/contexts/workspace-context"
import { saveFolderOpenedConversations } from "@/lib/api" import { listOpenedTabs, saveOpenedTabs } from "@/lib/api"
import type { import type { AgentType, ConversationStatus, OpenedTab } from "@/lib/types"
AgentType,
ConversationStatus,
OpenedConversation,
} from "@/lib/types"
import { AGENT_DISPLAY_ORDER } from "@/lib/types" import { AGENT_DISPLAY_ORDER } from "@/lib/types"
interface TabItemInternal { interface TabItemInternal {
id: string id: string
kind: "conversation" kind: "conversation"
folderId: number
conversationId: number | null conversationId: number | null
/** The runtime session key used by ConversationRuntimeContext. /** The runtime session key used by ConversationRuntimeContext.
* For new conversations this is a virtual (negative) ID that differs * For new conversations this is a virtual (negative) ID that differs
@@ -41,21 +38,28 @@ export type TabItem = TabItemInternal
interface TabContextValue { interface TabContextValue {
tabs: TabItem[] tabs: TabItem[]
activeTabId: string | null activeTabId: string | null
tabsHydrated: boolean
isTileMode: boolean isTileMode: boolean
openTab: ( openTab: (
folderId: number,
conversationId: number, conversationId: number,
agentType: AgentType, agentType: AgentType,
pin?: boolean, pin?: boolean,
title?: string title?: string
) => void ) => void
closeTab: (tabId: string) => void closeTab: (tabId: string) => void
closeConversationTab: (conversationId: number, agentType: AgentType) => void closeConversationTab: (
folderId: number,
conversationId: number,
agentType: AgentType
) => void
closeOtherTabs: (tabId: string) => void closeOtherTabs: (tabId: string) => void
closeAllTabs: () => void closeAllTabs: () => void
closeTabsByFolder: (folderId: number) => void
switchTab: (tabId: string) => void switchTab: (tabId: string) => void
pinTab: (tabId: string) => void pinTab: (tabId: string) => void
toggleTileMode: () => void toggleTileMode: () => void
openNewConversationTab: (workingDir: string) => void openNewConversationTab: (folderId: number, workingDir: string) => void
bindConversationTab: ( bindConversationTab: (
tabId: string, tabId: string,
conversationId: number, conversationId: number,
@@ -67,6 +71,7 @@ interface TabContextValue {
tabId: string, tabId: string,
runtimeConversationId: number runtimeConversationId: number
) => void ) => void
setTabFolder: (tabId: string, folderId: number, workingDir: string) => void
reorderTabs: (reorderedTabs: TabItem[]) => void reorderTabs: (reorderedTabs: TabItem[]) => void
onPreviewTabReplaced: (callback: (tabId: string) => void) => () => void onPreviewTabReplaced: (callback: (tabId: string) => void) => () => void
} }
@@ -82,25 +87,31 @@ export function useTabContext() {
} }
function makeConversationTabId( function makeConversationTabId(
folderId: number,
agentType: AgentType, agentType: AgentType,
conversationId: number conversationId: number
): string { ): string {
return `conv-${agentType}-${conversationId}` return `conv-${folderId}-${agentType}-${conversationId}`
} }
function makeNewConversationTabId(): string { function makeNewConversationTabId(): string {
return `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` return `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
} }
function findTabIndexForConversation( function findTabIndexForConversation(
tabs: TabItemInternal[], tabs: TabItemInternal[],
folderId: number,
agentType: AgentType, agentType: AgentType,
conversationId: number conversationId: number
): number { ): number {
const canonicalId = makeConversationTabId(agentType, conversationId) const canonicalId = makeConversationTabId(folderId, agentType, conversationId)
const idx = tabs.findIndex((t) => t.id === canonicalId) const idx = tabs.findIndex((t) => t.id === canonicalId)
if (idx >= 0) return idx if (idx >= 0) return idx
return tabs.findIndex( 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 children: ReactNode
} }
const TILE_MODE_STORAGE_KEY = "workspace:tile-mode"
export function TabProvider({ children }: TabProviderProps) { export function TabProvider({ children }: TabProviderProps) {
const t = useTranslations("Folder.tabContext") const t = useTranslations("Folder.tabContext")
const { activateConversationPane } = useWorkspaceContext() const { activateConversationPane } = useWorkspaceContext()
const { const { conversations, folders, setActiveFolderId } = useAppWorkspace()
folder,
folderId,
selectedConversation,
selectConversation,
clearSelection,
startNewConversation,
cancelNewConversation,
conversations,
} = useFolderContext()
const [rawTabs, setTabs] = useState<TabItemInternal[]>(() => { const [rawTabs, setTabs] = useState<TabItemInternal[]>([])
if (selectedConversation) { const [activeTabId, setActiveTabId] = useState<string | null>(null)
const tabId = makeConversationTabId( const [tabsHydrated, setTabsHydrated] = useState(false)
selectedConversation.agentType,
selectedConversation.id
)
return [
{
id: tabId,
kind: "conversation",
conversationId: selectedConversation.id,
agentType: selectedConversation.agentType,
title: t("loadingConversation"),
isPinned: true,
},
]
}
return []
})
const [activeTabId, setActiveTabId] = useState<string | null>(() => { // Refs for volatile state
if (selectedConversation) {
return makeConversationTabId(
selectedConversation.agentType,
selectedConversation.id
)
}
return null
})
// Refs for volatile state — used in callbacks to avoid re-creation
const activeTabIdRef = useRef(activeTabId) const activeTabIdRef = useRef(activeTabId)
useEffect(() => { useEffect(() => {
activeTabIdRef.current = activeTabId activeTabIdRef.current = activeTabId
@@ -163,11 +141,24 @@ export function TabProvider({ children }: TabProviderProps) {
rawTabsRef.current = rawTabs rawTabsRef.current = rawTabs
}, [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) const conversationsRef = useRef(conversations)
useEffect(() => { useEffect(() => {
conversationsRef.current = conversations conversationsRef.current = conversations
}, [conversations]) }, [conversations])
const foldersRef = useRef(folders)
useEffect(() => {
foldersRef.current = folders
}, [folders])
// Callback set for preview tab replacement notifications // Callback set for preview tab replacement notifications
const previewReplacedCallbacksRef = useRef(new Set<(tabId: string) => void>()) const previewReplacedCallbacksRef = useRef(new Set<(tabId: string) => void>())
const onPreviewTabReplaced = useCallback( const onPreviewTabReplaced = useCallback(
@@ -180,92 +171,75 @@ export function TabProvider({ children }: TabProviderProps) {
[] []
) )
// Restore tabs from folder.opened_conversations when folder first loads // Hydrate from persisted opened_tabs on mount
const [restoredFolderId, setRestoredFolderId] = useState<number | null>(() =>
selectedConversation ? folderId : null
)
useEffect(() => { useEffect(() => {
if (!folder) return
if (restoredFolderId === folder.id) return
let cancelled = false let cancelled = false
queueMicrotask(() => { void (async () => {
try {
const items = await listOpenedTabs()
if (cancelled) return if (cancelled) return
const restored: TabItemInternal[] = items.map((it) => ({
setRestoredFolderId(folder.id) id:
it.conversation_id != null
const opened = folder.opened_conversations ? makeConversationTabId(
if (opened.length === 0) return it.folder_id,
it.agent_type,
const restoredTabs: TabItemInternal[] = opened.map((oc) => ({ it.conversation_id
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)
) )
}) : 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 () => { return () => {
cancelled = true cancelled = true
} }
}, [folder, restoredFolderId, t]) }, [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])
// Debounced save to DB // Debounced save to DB
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const skipSaveRef = useRef(true) // skip saving until first restore completes
useEffect(() => { useEffect(() => {
// Skip the initial render and restoration phase if (!tabsHydrated) return
if (skipSaveRef.current) {
if (restoredFolderId != null) {
skipSaveRef.current = false
}
return
}
if (saveTimerRef.current) { if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current) clearTimeout(saveTimerRef.current)
} }
saveTimerRef.current = setTimeout(() => { saveTimerRef.current = setTimeout(() => {
const items: OpenedConversation[] = rawTabs const items: OpenedTab[] = rawTabs.map((tab, i) => ({
.filter( id: 0,
(t): t is TabItemInternal & { conversationId: number } => folder_id: tab.folderId,
t.conversationId != null conversation_id: tab.conversationId,
) agent_type: tab.agentType,
.map((t, i) => ({
conversation_id: t.conversationId,
agent_type: t.agentType,
position: i, position: i,
is_active: t.id === activeTabId, is_active: tab.id === activeTabId,
is_pinned: t.isPinned, is_pinned: tab.isPinned,
})) }))
saveFolderOpenedConversations(folderId, items).catch(() => { saveOpenedTabs(items).catch(() => {
// Silently ignore save errors // Silently ignore save errors
}) })
}, 500) }, 500)
@@ -275,13 +249,13 @@ export function TabProvider({ children }: TabProviderProps) {
clearTimeout(saveTimerRef.current) clearTimeout(saveTimerRef.current)
} }
} }
}, [rawTabs, activeTabId, folderId, restoredFolderId]) }, [rawTabs, activeTabId, tabsHydrated])
// Pre-index conversations for O(1) lookup in tabs derivation // Pre-index conversations for O(1) lookup in tabs derivation
const conversationMap = useMemo(() => { const conversationMap = useMemo(() => {
const m = new Map<string, (typeof conversations)[number]>() const m = new Map<string, (typeof conversations)[number]>()
for (const c of conversations) { 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 return m
}, [conversations]) }, [conversations])
@@ -292,7 +266,7 @@ export function TabProvider({ children }: TabProviderProps) {
return rawTabs.map((tab) => { return rawTabs.map((tab) => {
if (tab.conversationId != null) { if (tab.conversationId != null) {
const conv = conversationMap.get( const conv = conversationMap.get(
`${tab.agentType}-${tab.conversationId}` `${tab.folderId}-${tab.agentType}-${tab.conversationId}`
) )
if (conv) { if (conv) {
const newTitle = conv.title || t("untitledConversation") const newTitle = conv.title || t("untitledConversation")
@@ -306,36 +280,9 @@ export function TabProvider({ children }: TabProviderProps) {
}) })
}, [rawTabs, conversationMap, t]) }, [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( const openTab = useCallback(
( (
folderId: number,
conversationId: number, conversationId: number,
agentType: AgentType, agentType: AgentType,
pin = false, pin = false,
@@ -347,6 +294,7 @@ export function TabProvider({ children }: TabProviderProps) {
setTabs((prev) => { setTabs((prev) => {
const existingIndex = findTabIndexForConversation( const existingIndex = findTabIndexForConversation(
prev, prev,
folderId,
agentType, agentType,
conversationId conversationId
) )
@@ -364,19 +312,22 @@ export function TabProvider({ children }: TabProviderProps) {
return prev return prev
} }
// Resolve title from conversations list (via ref)
const resolvedTitle = const resolvedTitle =
title ?? title ??
conversationsRef.current.find( conversationsRef.current.find(
(c) => c.id === conversationId && c.agent_type === agentType (c) =>
c.id === conversationId &&
c.agent_type === agentType &&
c.folder_id === folderId
)?.title ?? )?.title ??
t("untitledConversation") t("untitledConversation")
const tabId = makeConversationTabId(agentType, conversationId) const tabId = makeConversationTabId(folderId, agentType, conversationId)
activateTabId = tabId activateTabId = tabId
const newTab: TabItemInternal = { const newTab: TabItemInternal = {
id: tabId, id: tabId,
kind: "conversation", kind: "conversation",
folderId,
conversationId, conversationId,
agentType, agentType,
title: resolvedTitle, title: resolvedTitle,
@@ -387,7 +338,6 @@ export function TabProvider({ children }: TabProviderProps) {
return [...prev, newTab] return [...prev, newTab]
} }
// Preview (not pinned): replace existing preview tab
const previewIndex = prev.findIndex((t) => !t.isPinned) const previewIndex = prev.findIndex((t) => !t.isPinned)
if (previewIndex >= 0) { if (previewIndex >= 0) {
replacedPreviewTabId = prev[previewIndex].id replacedPreviewTabId = prev[previewIndex].id
@@ -399,7 +349,6 @@ export function TabProvider({ children }: TabProviderProps) {
return [...prev, newTab] return [...prev, newTab]
}) })
// Notify listeners about the replaced preview tab
if (replacedPreviewTabId) { if (replacedPreviewTabId) {
for (const cb of previewReplacedCallbacksRef.current) { for (const cb of previewReplacedCallbacksRef.current) {
cb(replacedPreviewTabId) cb(replacedPreviewTabId)
@@ -409,30 +358,36 @@ export function TabProvider({ children }: TabProviderProps) {
if (activateTabId) { if (activateTabId) {
setActiveTabId(activateTabId) setActiveTabId(activateTabId)
} }
selectConversation(conversationId, agentType)
activateConversationPane() activateConversationPane()
}, },
[activateConversationPane, selectConversation, t] [activateConversationPane, t]
) )
const makeReplacementDraftTab = useCallback( const makeReplacementDraftTab = useCallback(
(preferred?: TabItemInternal): TabItemInternal => ({ (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(), id: makeNewConversationTabId(),
kind: "conversation", kind: "conversation",
folderId,
conversationId: null, conversationId: null,
agentType: AGENT_DISPLAY_ORDER[0], agentType: AGENT_DISPLAY_ORDER[0],
title: t("newConversation"), title: t("newConversation"),
isPinned: true, isPinned: true,
workingDir: preferred?.workingDir ?? folder?.path, workingDir,
}), }
[folder?.path, t] },
[t]
) )
const tileModeKey = `folder:${folderId}:tile-mode`
const [isTileMode, setIsTileMode] = useState(() => { const [isTileMode, setIsTileMode] = useState(() => {
if (typeof window === "undefined") return false if (typeof window === "undefined") return false
try { try {
return localStorage.getItem(tileModeKey) === "true" return localStorage.getItem(TILE_MODE_STORAGE_KEY) === "true"
} catch { } catch {
return false return false
} }
@@ -440,15 +395,16 @@ export function TabProvider({ children }: TabProviderProps) {
useEffect(() => { useEffect(() => {
try { try {
localStorage.setItem(tileModeKey, String(isTileMode)) localStorage.setItem(TILE_MODE_STORAGE_KEY, String(isTileMode))
} catch { } catch {
/* ignore */ /* ignore */
} }
}, [isTileMode, tileModeKey]) }, [isTileMode])
const closeTab = useCallback( const closeTab = useCallback(
(tabId: string) => { (tabId: string) => {
let neighborToSync: TabItemInternal | undefined let neighborToSync: TabItemInternal | undefined
let shouldReplaceWithEmpty = false
setTabs((prev) => { setTabs((prev) => {
const index = prev.findIndex((t) => t.id === tabId) 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) const next = prev.filter((t) => t.id !== tabId)
if (next.length === 0) { if (next.length === 0) {
if (foldersRef.current.length === 0) {
shouldReplaceWithEmpty = true
return []
}
const replacementTab = makeReplacementDraftTab(closingTab) const replacementTab = makeReplacementDraftTab(closingTab)
neighborToSync = replacementTab neighborToSync = replacementTab
return [replacementTab] return [replacementTab]
} }
// If closing the active tab, pick a neighbor to activate
if (tabId === activeTabIdRef.current) { if (tabId === activeTabIdRef.current) {
// Prefer right neighbor, then left
const newIndex = Math.min(index, next.length - 1) const newIndex = Math.min(index, next.length - 1)
neighborToSync = next[newIndex] neighborToSync = next[newIndex]
} }
@@ -473,22 +431,26 @@ export function TabProvider({ children }: TabProviderProps) {
return next return next
}) })
// Sync folder context outside the updater to avoid if (shouldReplaceWithEmpty) {
// updating FolderProvider state during TabProvider render setActiveTabId(null)
return
}
if (neighborToSync) { if (neighborToSync) {
setActiveTabId(neighborToSync.id) setActiveTabId(neighborToSync.id)
syncFolderContext(neighborToSync)
activateConversationPane() activateConversationPane()
} }
}, },
[activateConversationPane, makeReplacementDraftTab, syncFolderContext] [activateConversationPane, makeReplacementDraftTab]
) )
const closeConversationTab = useCallback( const closeConversationTab = useCallback(
(conversationId: number, agentType: AgentType) => { (folderId: number, conversationId: number, agentType: AgentType) => {
const target = rawTabsRef.current.find( const target = rawTabsRef.current.find(
(tab) => (tab) =>
tab.conversationId === conversationId && tab.agentType === agentType tab.folderId === folderId &&
tab.conversationId === conversationId &&
tab.agentType === agentType
) )
if (!target) return if (!target) return
closeTab(target.id) closeTab(target.id)
@@ -496,20 +458,13 @@ export function TabProvider({ children }: TabProviderProps) {
[closeTab] [closeTab]
) )
const closeOtherTabs = useCallback( const closeOtherTabs = useCallback((tabId: string) => {
(tabId: string) => {
setTabs((prev) => { setTabs((prev) => {
const kept = prev.filter((t) => t.id === tabId) const kept = prev.filter((t) => t.id === tabId)
return kept.length === prev.length ? prev : kept return kept.length === prev.length ? prev : kept
}) })
const tab = rawTabsRef.current.find((t) => t.id === tabId)
if (tab) {
setActiveTabId(tabId) setActiveTabId(tabId)
syncFolderContext(tab) }, [])
}
},
[syncFolderContext]
)
const closeAllTabs = useCallback(() => { const closeAllTabs = useCallback(() => {
const seedTab = const seedTab =
@@ -519,12 +474,33 @@ export function TabProvider({ children }: TabProviderProps) {
rawTabsRef.current.find((t) => t.id === activeTabIdRef.current) ?? rawTabsRef.current.find((t) => t.id === activeTabIdRef.current) ??
rawTabsRef.current[0] rawTabsRef.current[0]
if (foldersRef.current.length === 0) {
setTabs([])
setActiveTabId(null)
return
}
const replacementTab = makeReplacementDraftTab(seedTab) const replacementTab = makeReplacementDraftTab(seedTab)
setTabs([replacementTab]) setTabs([replacementTab])
setActiveTabId(replacementTab.id) setActiveTabId(replacementTab.id)
syncFolderContext(replacementTab)
activateConversationPane() 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( const switchTab = useCallback(
(tabId: string) => { (tabId: string) => {
@@ -532,10 +508,9 @@ export function TabProvider({ children }: TabProviderProps) {
if (!tab) return if (!tab) return
setActiveTabId(tabId) setActiveTabId(tabId)
syncFolderContext(tab)
activateConversationPane() activateConversationPane()
}, },
[activateConversationPane, syncFolderContext] [activateConversationPane]
) )
const pinTab = useCallback((tabId: string) => { const pinTab = useCallback((tabId: string) => {
@@ -554,13 +529,13 @@ export function TabProvider({ children }: TabProviderProps) {
) )
const openNewConversationTab = useCallback( const openNewConversationTab = useCallback(
(workingDir: string) => { (folderId: number, workingDir: string) => {
// Reuse existing draft tab for the same folder if present
const existingTab = rawTabsRef.current.find( const existingTab = rawTabsRef.current.find(
(t) => t.conversationId == null (t) => t.conversationId == null && t.folderId === folderId
) )
if (existingTab) { if (existingTab) {
// Update workingDir if it differs from the request
if (existingTab.workingDir !== workingDir) { if (existingTab.workingDir !== workingDir) {
setTabs((prev) => setTabs((prev) =>
prev.map((t) => prev.map((t) =>
@@ -569,7 +544,6 @@ export function TabProvider({ children }: TabProviderProps) {
) )
} }
setActiveTabId(existingTab.id) setActiveTabId(existingTab.id)
syncFolderContext(existingTab)
activateConversationPane() activateConversationPane()
return return
} }
@@ -579,6 +553,7 @@ export function TabProvider({ children }: TabProviderProps) {
const newTab: TabItemInternal = { const newTab: TabItemInternal = {
id: tabId, id: tabId,
kind: "conversation", kind: "conversation",
folderId,
conversationId: null, conversationId: null,
agentType, agentType,
title: t("newConversation"), title: t("newConversation"),
@@ -588,10 +563,9 @@ export function TabProvider({ children }: TabProviderProps) {
setTabs((prev) => [...prev, newTab]) setTabs((prev) => [...prev, newTab])
setActiveTabId(tabId) setActiveTabId(tabId)
startNewConversation(workingDir)
activateConversationPane() activateConversationPane()
}, },
[activateConversationPane, startNewConversation, syncFolderContext, t] [activateConversationPane, t]
) )
const bindConversationTab = useCallback( const bindConversationTab = useCallback(
@@ -606,7 +580,7 @@ export function TabProvider({ children }: TabProviderProps) {
setTabs((prev) => setTabs((prev) =>
prev.flatMap((tab) => { prev.flatMap((tab) => {
if (tab.id === tabId) { if (tab.id === tabId) {
const nextTab = { const nextTab: TabItemInternal = {
...tab, ...tab,
conversationId, conversationId,
agentType, agentType,
@@ -617,6 +591,7 @@ export function TabProvider({ children }: TabProviderProps) {
} }
if ( if (
tab.folderId === tab.folderId &&
tab.conversationId === conversationId && tab.conversationId === conversationId &&
tab.agentType === agentType tab.agentType === agentType
) { ) {
@@ -631,15 +606,9 @@ export function TabProvider({ children }: TabProviderProps) {
) )
if (nextActiveTabId) { if (nextActiveTabId) {
setActiveTabId(nextActiveTabId) setActiveTabId(nextActiveTabId)
const target = rawTabsRef.current.find(
(tab) => tab.id === nextActiveTabId
)
if (target) {
syncFolderContext(target)
}
} }
}, },
[syncFolderContext] []
) )
const setTabRuntimeConversationId = useCallback( 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( const value = useMemo(
() => ({ () => ({
tabs, tabs,
activeTabId, activeTabId,
tabsHydrated,
isTileMode, isTileMode,
openTab, openTab,
closeTab, closeTab,
closeConversationTab, closeConversationTab,
closeOtherTabs, closeOtherTabs,
closeAllTabs, closeAllTabs,
closeTabsByFolder,
switchTab, switchTab,
pinTab, pinTab,
toggleTileMode, toggleTileMode,
openNewConversationTab, openNewConversationTab,
bindConversationTab, bindConversationTab,
setTabRuntimeConversationId, setTabRuntimeConversationId,
setTabFolder,
reorderTabs, reorderTabs,
onPreviewTabReplaced, onPreviewTabReplaced,
}), }),
[ [
tabs, tabs,
activeTabId, activeTabId,
tabsHydrated,
isTileMode, isTileMode,
openTab, openTab,
closeTab, closeTab,
closeConversationTab, closeConversationTab,
closeOtherTabs, closeOtherTabs,
closeAllTabs, closeAllTabs,
closeTabsByFolder,
switchTab, switchTab,
pinTab, pinTab,
toggleTileMode, toggleTileMode,
openNewConversationTab, openNewConversationTab,
bindConversationTab, bindConversationTab,
setTabRuntimeConversationId, setTabRuntimeConversationId,
setTabFolder,
reorderTabs, reorderTabs,
onPreviewTabReplaced, onPreviewTabReplaced,
] ]
+22 -8
View File
@@ -12,12 +12,13 @@ import {
} from "react" } from "react"
import { terminalKill } from "@/lib/api" import { terminalKill } from "@/lib/api"
import { randomUUID } from "@/lib/utils" 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 { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
export interface TerminalTab { export interface TerminalTab {
id: string id: string
folderId: number
title: string title: string
workingDir: string workingDir: string
initialCommand?: string initialCommand?: string
@@ -65,7 +66,7 @@ export function useTerminalContext() {
} }
export function TerminalProvider({ children }: { children: ReactNode }) { export function TerminalProvider({ children }: { children: ReactNode }) {
const { folder } = useFolderContext() const { activeFolder, activeFolderId } = useActiveFolder()
const { shortcuts } = useShortcutSettings() const { shortcuts } = useShortcutSettings()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [height, setHeightState] = useState(DEFAULT_HEIGHT) const [height, setHeightState] = useState(DEFAULT_HEIGHT)
@@ -80,7 +81,8 @@ export function TerminalProvider({ children }: { children: ReactNode }) {
tabsRef.current = tabs tabsRef.current = tabs
}, [tabs]) }, [tabs])
const folderPath = folder?.path ?? "" const folderPath = activeFolder?.path ?? ""
const currentFolderId = activeFolderId ?? 0
const markTerminalExited = useCallback((id: string) => { const markTerminalExited = useCallback((id: string) => {
setExitedTerminals((prev) => { setExitedTerminals((prev) => {
@@ -122,6 +124,7 @@ export function TerminalProvider({ children }: { children: ReactNode }) {
return [ return [
{ {
id: autoId, id: autoId,
folderId: currentFolderId,
title: `Terminal ${nextCounter}`, title: `Terminal ${nextCounter}`,
workingDir: folderPath, workingDir: folderPath,
}, },
@@ -133,7 +136,7 @@ export function TerminalProvider({ children }: { children: ReactNode }) {
if (!folderPath) return null if (!folderPath) return null
return autoId return autoId
}) })
}, [folderPath]) }, [folderPath, currentFolderId])
const createTerminalWithCommand = useCallback( const createTerminalWithCommand = useCallback(
async (title: string, command: string) => { async (title: string, command: string) => {
@@ -145,13 +148,19 @@ export function TerminalProvider({ children }: { children: ReactNode }) {
tabCounterRef.current += 1 tabCounterRef.current += 1
setTabs((prev) => [ setTabs((prev) => [
...prev, ...prev,
{ id, title, workingDir: folderPath, initialCommand: command }, {
id,
folderId: currentFolderId,
title,
workingDir: folderPath,
initialCommand: command,
},
]) ])
setActiveTabId(id) setActiveTabId(id)
return id return id
}, },
[folderPath] [folderPath, currentFolderId]
) )
const createTerminalInDirectory = useCallback( const createTerminalInDirectory = useCallback(
@@ -165,13 +174,18 @@ export function TerminalProvider({ children }: { children: ReactNode }) {
const defaultTitle = `Terminal ${tabCounterRef.current}` const defaultTitle = `Terminal ${tabCounterRef.current}`
setTabs((prev) => [ setTabs((prev) => [
...prev, ...prev,
{ id, title: title ?? defaultTitle, workingDir }, {
id,
folderId: currentFolderId,
title: title ?? defaultTitle,
workingDir,
},
]) ])
setActiveTabId(id) setActiveTabId(id)
return id return id
}, },
[] [currentFolderId]
) )
const createTerminal = useCallback(async () => { const createTerminal = useCallback(async () => {
+17 -7
View File
@@ -11,7 +11,7 @@ import {
type ReactNode, type ReactNode,
} from "react" } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { useFolderContext } from "@/contexts/folder-context" import { useActiveFolder } from "@/contexts/active-folder-context"
import { import {
gitDiff, gitDiff,
gitDiffWithBranch, gitDiffWithBranch,
@@ -208,12 +208,11 @@ interface WorkspaceProviderProps {
export function WorkspaceProvider({ children }: WorkspaceProviderProps) { export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
const t = useTranslations("Folder.workspaceContext") const t = useTranslations("Folder.workspaceContext")
const { folder, folderId } = useFolderContext() const { activeFolder, activeFolderId } = useActiveFolder()
const folderPath = folder?.path const folderPath = activeFolder?.path
const storageKey = useMemo( const storageKey = "workspace:mode"
() => `folder:${folderId}:workspace-mode`, /* activeFolderId used in effect below to reset file tabs on folder switch */
[folderId] void activeFolderId
)
const [mode, setModeState] = useState<WorkspaceMode>(DEFAULT_WORKSPACE_MODE) const [mode, setModeState] = useState<WorkspaceMode>(DEFAULT_WORKSPACE_MODE)
const [activePane, setActivePaneState] = const [activePane, setActivePaneState] =
useState<WorkspacePane>("conversation") useState<WorkspacePane>("conversation")
@@ -247,6 +246,17 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
setRestored(true) setRestored(true)
}, [storageKey]) }, [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(() => { useEffect(() => {
if (!restored) return if (!restored) return
savePersistedWorkspaceMode(storageKey, mode) savePersistedWorkspaceMode(storageKey, mode)
+42 -3
View File
@@ -825,7 +825,24 @@
"noNewSessionsFound": "لم يتم العثور على جلسات جديدة (تم تخطي {skipped})", "noNewSessionsFound": "لم يتم العثور على جلسات جديدة (تم تخطي {skipped})",
"importFailed": "فشل الاستيراد: {message}", "importFailed": "فشل الاستيراد: {message}",
"reviewCompleted": "تم وضع علامة مكتمل على {count, plural, one {# جلسة مراجعة} other {# جلسات مراجعة}}", "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": { "conversation": {
@@ -960,7 +977,9 @@
"closeOthers": "إغلاق البقية", "closeOthers": "إغلاق البقية",
"closeAll": "إغلاق الكل", "closeAll": "إغلاق الكل",
"tileDisplay": "عرض متجانب", "tileDisplay": "عرض متجانب",
"untileDisplay": "إلغاء التجانب" "untileDisplay": "إلغاء التجانب",
"closeFolderTabs": "إغلاق جميع علامات التبويب لـ {folder}",
"revealInSidebar": "إظهار في الشريط الجانبي"
}, },
"fileWorkspace": { "fileWorkspace": {
"files": "الملفات", "files": "الملفات",
@@ -976,7 +995,8 @@
"close": "إغلاق", "close": "إغلاق",
"closeOthers": "إغلاق البقية", "closeOthers": "إغلاق البقية",
"closeAll": "إغلاق الكل", "closeAll": "إغلاق الكل",
"hideTerminal": "إخفاء الطرفية ({shortcut})" "hideTerminal": "إخفاء الطرفية ({shortcut})",
"openFolderFirst": "افتح مجلدا أولا"
}, },
"sessionFiles": { "sessionFiles": {
"currentResponse": "الاستجابة الحالية", "currentResponse": "الاستجابة الحالية",
@@ -1792,6 +1812,25 @@
"hunkLabel": "مقطع {index}", "hunkLabel": "مقطع {index}",
"loadingHunk": "جارٍ تحميل hunk...", "loadingHunk": "جارٍ تحميل hunk...",
"noDiffData": "لا توجد بيانات diff" "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": { "ProjectBoot": {
+42 -3
View File
@@ -825,7 +825,24 @@
"noNewSessionsFound": "Keine neuen Sitzungen gefunden ({skipped} übersprungen)", "noNewSessionsFound": "Keine neuen Sitzungen gefunden ({skipped} übersprungen)",
"importFailed": "Import fehlgeschlagen: {message}", "importFailed": "Import fehlgeschlagen: {message}",
"reviewCompleted": "{count, plural, one {# Review-Sitzung} other {# Review-Sitzungen}} als abgeschlossen markiert", "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": { "conversation": {
@@ -960,7 +977,9 @@
"closeOthers": "Andere schließen", "closeOthers": "Andere schließen",
"closeAll": "Alle schließen", "closeAll": "Alle schließen",
"tileDisplay": "Kachelansicht", "tileDisplay": "Kachelansicht",
"untileDisplay": "Kachel beenden" "untileDisplay": "Kachel beenden",
"closeFolderTabs": "Alle Tabs von {folder} schließen",
"revealInSidebar": "In Seitenleiste anzeigen"
}, },
"fileWorkspace": { "fileWorkspace": {
"files": "Dateien", "files": "Dateien",
@@ -976,7 +995,8 @@
"close": "Schließen", "close": "Schließen",
"closeOthers": "Andere schließen", "closeOthers": "Andere schließen",
"closeAll": "Alle schließen", "closeAll": "Alle schließen",
"hideTerminal": "Terminal ausblenden ({shortcut})" "hideTerminal": "Terminal ausblenden ({shortcut})",
"openFolderFirst": "Öffnen Sie zuerst einen Ordner"
}, },
"sessionFiles": { "sessionFiles": {
"currentResponse": "Aktuelle Antwort", "currentResponse": "Aktuelle Antwort",
@@ -1792,6 +1812,25 @@
"hunkLabel": "Block {index}", "hunkLabel": "Block {index}",
"loadingHunk": "Hunk wird geladen...", "loadingHunk": "Hunk wird geladen...",
"noDiffData": "Keine Diff-Daten" "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": { "ProjectBoot": {
+42 -3
View File
@@ -825,7 +825,24 @@
"noNewSessionsFound": "No new sessions found (skipped {skipped})", "noNewSessionsFound": "No new sessions found (skipped {skipped})",
"importFailed": "Import failed: {message}", "importFailed": "Import failed: {message}",
"reviewCompleted": "Marked {count, plural, one {# review session} other {# review sessions}} as completed", "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": { "conversation": {
@@ -960,7 +977,9 @@
"closeOthers": "Close Others", "closeOthers": "Close Others",
"closeAll": "Close All", "closeAll": "Close All",
"tileDisplay": "Tile Display", "tileDisplay": "Tile Display",
"untileDisplay": "Exit Tile" "untileDisplay": "Exit Tile",
"closeFolderTabs": "Close all tabs of {folder}",
"revealInSidebar": "Reveal in sidebar"
}, },
"fileWorkspace": { "fileWorkspace": {
"files": "Files", "files": "Files",
@@ -976,7 +995,8 @@
"close": "Close", "close": "Close",
"closeOthers": "Close Others", "closeOthers": "Close Others",
"closeAll": "Close All", "closeAll": "Close All",
"hideTerminal": "Hide Terminal ({shortcut})" "hideTerminal": "Hide Terminal ({shortcut})",
"openFolderFirst": "Open a folder first"
}, },
"sessionFiles": { "sessionFiles": {
"currentResponse": "Current response", "currentResponse": "Current response",
@@ -1792,6 +1812,25 @@
"hunkLabel": "Hunk {index}", "hunkLabel": "Hunk {index}",
"loadingHunk": "Loading hunk...", "loadingHunk": "Loading hunk...",
"noDiffData": "No diff data" "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": { "ProjectBoot": {
+42 -3
View File
@@ -825,7 +825,24 @@
"noNewSessionsFound": "No se encontraron sesiones nuevas (omitidas {skipped})", "noNewSessionsFound": "No se encontraron sesiones nuevas (omitidas {skipped})",
"importFailed": "Error al importar: {message}", "importFailed": "Error al importar: {message}",
"reviewCompleted": "Se marcaron como completadas {count, plural, one {# sesión en revisión} other {# sesiones en revisión}}", "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": { "conversation": {
@@ -960,7 +977,9 @@
"closeOthers": "Cerrar otros", "closeOthers": "Cerrar otros",
"closeAll": "Cerrar todo", "closeAll": "Cerrar todo",
"tileDisplay": "Vista en mosaico", "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": { "fileWorkspace": {
"files": "Archivos", "files": "Archivos",
@@ -976,7 +995,8 @@
"close": "Cerrar", "close": "Cerrar",
"closeOthers": "Cerrar otros", "closeOthers": "Cerrar otros",
"closeAll": "Cerrar todo", "closeAll": "Cerrar todo",
"hideTerminal": "Ocultar terminal ({shortcut})" "hideTerminal": "Ocultar terminal ({shortcut})",
"openFolderFirst": "Abre primero una carpeta"
}, },
"sessionFiles": { "sessionFiles": {
"currentResponse": "Respuesta actual", "currentResponse": "Respuesta actual",
@@ -1792,6 +1812,25 @@
"hunkLabel": "Bloque {index}", "hunkLabel": "Bloque {index}",
"loadingHunk": "Cargando hunk...", "loadingHunk": "Cargando hunk...",
"noDiffData": "Sin datos de diff" "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": { "ProjectBoot": {
+42 -3
View File
@@ -825,7 +825,24 @@
"noNewSessionsFound": "Aucune nouvelle session trouvée ({skipped} ignorée(s))", "noNewSessionsFound": "Aucune nouvelle session trouvée ({skipped} ignorée(s))",
"importFailed": "Échec de l’import : {message}", "importFailed": "Échec de l’import : {message}",
"reviewCompleted": "{count, plural, one {# session en revue} other {# sessions en revue}} marquée(s) comme terminée(s)", "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": { "conversation": {
@@ -960,7 +977,9 @@
"closeOthers": "Fermer les autres", "closeOthers": "Fermer les autres",
"closeAll": "Tout fermer", "closeAll": "Tout fermer",
"tileDisplay": "Affichage en mosaïque", "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": { "fileWorkspace": {
"files": "Fichiers", "files": "Fichiers",
@@ -976,7 +995,8 @@
"close": "Fermer", "close": "Fermer",
"closeOthers": "Fermer les autres", "closeOthers": "Fermer les autres",
"closeAll": "Tout fermer", "closeAll": "Tout fermer",
"hideTerminal": "Masquer le terminal ({shortcut})" "hideTerminal": "Masquer le terminal ({shortcut})",
"openFolderFirst": "Ouvrez d'abord un dossier"
}, },
"sessionFiles": { "sessionFiles": {
"currentResponse": "Réponse actuelle", "currentResponse": "Réponse actuelle",
@@ -1792,6 +1812,25 @@
"hunkLabel": "Bloc {index}", "hunkLabel": "Bloc {index}",
"loadingHunk": "Chargement du hunk...", "loadingHunk": "Chargement du hunk...",
"noDiffData": "Aucune donnée diff" "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": { "ProjectBoot": {
+42 -3
View File
@@ -825,7 +825,24 @@
"noNewSessionsFound": "新しいセッションは見つかりませんでした({skipped} 件をスキップ)", "noNewSessionsFound": "新しいセッションは見つかりませんでした({skipped} 件をスキップ)",
"importFailed": "インポートに失敗しました: {message}", "importFailed": "インポートに失敗しました: {message}",
"reviewCompleted": "{count, plural, one {# 件のレビューセッション} other {# 件のレビューセッション}} を完了にしました", "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": { "conversation": {
@@ -960,7 +977,9 @@
"closeOthers": "他を閉じる", "closeOthers": "他を閉じる",
"closeAll": "すべて閉じる", "closeAll": "すべて閉じる",
"tileDisplay": "タイル表示", "tileDisplay": "タイル表示",
"untileDisplay": "タイル解除" "untileDisplay": "タイル解除",
"closeFolderTabs": "{folder} のすべてのタブを閉じる",
"revealInSidebar": "サイドバーで表示"
}, },
"fileWorkspace": { "fileWorkspace": {
"files": "ファイル", "files": "ファイル",
@@ -976,7 +995,8 @@
"close": "閉じる", "close": "閉じる",
"closeOthers": "他を閉じる", "closeOthers": "他を閉じる",
"closeAll": "すべて閉じる", "closeAll": "すべて閉じる",
"hideTerminal": "ターミナルを隠す ({shortcut})" "hideTerminal": "ターミナルを隠す ({shortcut})",
"openFolderFirst": "先にフォルダを開いてください"
}, },
"sessionFiles": { "sessionFiles": {
"currentResponse": "現在の応答", "currentResponse": "現在の応答",
@@ -1792,6 +1812,25 @@
"hunkLabel": "ハンク {index}", "hunkLabel": "ハンク {index}",
"loadingHunk": "Hunk を読み込み中...", "loadingHunk": "Hunk を読み込み中...",
"noDiffData": "Diff データがありません" "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": { "ProjectBoot": {
+42 -3
View File
@@ -825,7 +825,24 @@
"noNewSessionsFound": "새 세션이 없습니다 ({skipped}개 건너뜀)", "noNewSessionsFound": "새 세션이 없습니다 ({skipped}개 건너뜀)",
"importFailed": "가져오기 실패: {message}", "importFailed": "가져오기 실패: {message}",
"reviewCompleted": "{count, plural, one {#개 검토 세션} other {#개 검토 세션}}을 완료로 표시했습니다", "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": { "conversation": {
@@ -960,7 +977,9 @@
"closeOthers": "다른 항목 닫기", "closeOthers": "다른 항목 닫기",
"closeAll": "모두 닫기", "closeAll": "모두 닫기",
"tileDisplay": "타일 표시", "tileDisplay": "타일 표시",
"untileDisplay": "타일 해제" "untileDisplay": "타일 해제",
"closeFolderTabs": "{folder}의 모든 탭 닫기",
"revealInSidebar": "사이드바에서 표시"
}, },
"fileWorkspace": { "fileWorkspace": {
"files": "파일", "files": "파일",
@@ -976,7 +995,8 @@
"close": "닫기", "close": "닫기",
"closeOthers": "다른 항목 닫기", "closeOthers": "다른 항목 닫기",
"closeAll": "모두 닫기", "closeAll": "모두 닫기",
"hideTerminal": "터미널 숨기기 ({shortcut})" "hideTerminal": "터미널 숨기기 ({shortcut})",
"openFolderFirst": "먼저 폴더를 여세요"
}, },
"sessionFiles": { "sessionFiles": {
"currentResponse": "현재 응답", "currentResponse": "현재 응답",
@@ -1792,6 +1812,25 @@
"hunkLabel": "청크 {index}", "hunkLabel": "청크 {index}",
"loadingHunk": "Hunk 로딩 중...", "loadingHunk": "Hunk 로딩 중...",
"noDiffData": "Diff 데이터 없음" "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": { "ProjectBoot": {
+42 -3
View File
@@ -825,7 +825,24 @@
"noNewSessionsFound": "Nenhuma nova sessão encontrada (ignoradas {skipped})", "noNewSessionsFound": "Nenhuma nova sessão encontrada (ignoradas {skipped})",
"importFailed": "Falha na importação: {message}", "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", "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": { "conversation": {
@@ -960,7 +977,9 @@
"closeOthers": "Fechar outros", "closeOthers": "Fechar outros",
"closeAll": "Fechar tudo", "closeAll": "Fechar tudo",
"tileDisplay": "Exibição em mosaico", "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": { "fileWorkspace": {
"files": "Arquivos", "files": "Arquivos",
@@ -976,7 +995,8 @@
"close": "Fechar", "close": "Fechar",
"closeOthers": "Fechar outros", "closeOthers": "Fechar outros",
"closeAll": "Fechar tudo", "closeAll": "Fechar tudo",
"hideTerminal": "Ocultar terminal ({shortcut})" "hideTerminal": "Ocultar terminal ({shortcut})",
"openFolderFirst": "Abra primeiro uma pasta"
}, },
"sessionFiles": { "sessionFiles": {
"currentResponse": "Resposta atual", "currentResponse": "Resposta atual",
@@ -1792,6 +1812,25 @@
"hunkLabel": "Bloco {index}", "hunkLabel": "Bloco {index}",
"loadingHunk": "Carregando hunk...", "loadingHunk": "Carregando hunk...",
"noDiffData": "Sem dados de diff" "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": { "ProjectBoot": {
+42 -3
View File
@@ -825,7 +825,24 @@
"noNewSessionsFound": "没有新会话(已跳过 {skipped} 个)", "noNewSessionsFound": "没有新会话(已跳过 {skipped} 个)",
"importFailed": "导入失败:{message}", "importFailed": "导入失败:{message}",
"reviewCompleted": "已将 {count} 个复查会话标记为已完成", "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": { "conversation": {
@@ -960,7 +977,9 @@
"closeOthers": "关闭其它", "closeOthers": "关闭其它",
"closeAll": "关闭所有", "closeAll": "关闭所有",
"tileDisplay": "平铺显示", "tileDisplay": "平铺显示",
"untileDisplay": "取消平铺" "untileDisplay": "取消平铺",
"closeFolderTabs": "关闭 {folder} 的所有标签",
"revealInSidebar": "在侧边栏中定位"
}, },
"fileWorkspace": { "fileWorkspace": {
"files": "文件", "files": "文件",
@@ -976,7 +995,8 @@
"close": "关闭", "close": "关闭",
"closeOthers": "关闭其它", "closeOthers": "关闭其它",
"closeAll": "关闭所有", "closeAll": "关闭所有",
"hideTerminal": "隐藏终端({shortcut})" "hideTerminal": "隐藏终端({shortcut})",
"openFolderFirst": "请先打开一个文件夹"
}, },
"sessionFiles": { "sessionFiles": {
"currentResponse": "当前响应", "currentResponse": "当前响应",
@@ -1792,6 +1812,25 @@
"hunkLabel": "代码块 {index}", "hunkLabel": "代码块 {index}",
"loadingHunk": "正在加载代码块...", "loadingHunk": "正在加载代码块...",
"noDiffData": "无差异数据" "noDiffData": "无差异数据"
},
"conversationContextBar": {
"searchFolder": "搜索文件夹...",
"searchBranch": "搜索分支...",
"noFolders": "暂无文件夹",
"noBranches": "暂无分支",
"noBranch": "(无分支)",
"openNewFolder": "从磁盘打开文件夹...",
"cancel": "取消",
"create": "创建",
"commit": "提交",
"push": "推送",
"merge": "合并",
"toasts": {
"folderChanged": "已切换到 {name}",
"openFolderFailed": "打开文件夹失败",
"openStashFailed": "打开贮藏窗口失败",
"openMergeFailed": "打开合并窗口失败"
}
} }
}, },
"ProjectBoot": { "ProjectBoot": {
+42 -3
View File
@@ -825,7 +825,24 @@
"noNewSessionsFound": "沒有新會話(已跳過 {skipped} 個)", "noNewSessionsFound": "沒有新會話(已跳過 {skipped} 個)",
"importFailed": "匯入失敗:{message}", "importFailed": "匯入失敗:{message}",
"reviewCompleted": "已將 {count} 個複查會話標記為已完成", "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": { "conversation": {
@@ -960,7 +977,9 @@
"closeOthers": "關閉其它", "closeOthers": "關閉其它",
"closeAll": "關閉所有", "closeAll": "關閉所有",
"tileDisplay": "平鋪顯示", "tileDisplay": "平鋪顯示",
"untileDisplay": "取消平鋪" "untileDisplay": "取消平鋪",
"closeFolderTabs": "關閉 {folder} 的所有標籤",
"revealInSidebar": "在側邊欄中定位"
}, },
"fileWorkspace": { "fileWorkspace": {
"files": "檔案", "files": "檔案",
@@ -976,7 +995,8 @@
"close": "關閉", "close": "關閉",
"closeOthers": "關閉其它", "closeOthers": "關閉其它",
"closeAll": "關閉所有", "closeAll": "關閉所有",
"hideTerminal": "隱藏終端({shortcut})" "hideTerminal": "隱藏終端({shortcut})",
"openFolderFirst": "請先開啟一個資料夾"
}, },
"sessionFiles": { "sessionFiles": {
"currentResponse": "目前回應", "currentResponse": "目前回應",
@@ -1792,6 +1812,25 @@
"hunkLabel": "區塊 {index}", "hunkLabel": "區塊 {index}",
"loadingHunk": "正在載入區塊...", "loadingHunk": "正在載入區塊...",
"noDiffData": "無差異資料" "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": { "ProjectBoot": {
+39 -57
View File
@@ -21,7 +21,7 @@ import type {
FolderDetail, FolderDetail,
DbConversationSummary, DbConversationSummary,
ImportResult, ImportResult,
OpenedConversation, OpenedTab,
GitStatusEntry, GitStatusEntry,
GitBranchList, GitBranchList,
GitPullResult, GitPullResult,
@@ -598,22 +598,48 @@ export async function getFolder(folderId: number): Promise<FolderDetail> {
return getTransport().call("get_folder", { folderId }) return getTransport().call("get_folder", { folderId })
} }
export async function listFolderConversations(params: { export async function listAllConversations(params?: {
folder_id: number folder_ids?: number[] | null
agent_type?: AgentType | null agent_type?: AgentType | null
search?: string | null search?: string | null
sort_by?: string | null sort_by?: string | null
status?: string | null status?: string | null
}): Promise<DbConversationSummary[]> { }): Promise<DbConversationSummary[]> {
return getTransport().call("list_folder_conversations", { return getTransport().call("list_all_conversations", {
folderId: params.folder_id, folderIds: params?.folder_ids ?? null,
agentType: params.agent_type ?? null, agentType: params?.agent_type ?? null,
search: params.search ?? null, search: params?.search ?? null,
sortBy: params.sort_by ?? null, sortBy: params?.sort_by ?? null,
status: params.status ?? null, status: params?.status ?? null,
}) })
} }
export async function listOpenedTabs(): Promise<OpenedTab[]> {
return getTransport().call("list_opened_tabs")
}
export async function saveOpenedTabs(items: OpenedTab[]): Promise<void> {
return getTransport().call("save_opened_tabs", { items })
}
export async function listOpenFolderDetails(): Promise<FolderDetail[]> {
return getTransport().call("list_open_folder_details")
}
export async function listAllFolderDetails(): Promise<FolderDetail[]> {
return getTransport().call("list_all_folder_details")
}
export async function openFolderById(folderId: number): Promise<FolderDetail> {
return getTransport().call("open_folder_by_id", { folderId })
}
export async function removeFolderFromWorkspace(
folderId: number
): Promise<void> {
return getTransport().call("remove_folder_from_workspace", { folderId })
}
export async function importLocalConversations( export async function importLocalConversations(
folderId: number folderId: number
): Promise<ImportResult> { ): Promise<ImportResult> {
@@ -626,16 +652,6 @@ export async function getFolderConversation(
return getTransport().call("get_folder_conversation", { conversationId }) return getTransport().call("get_folder_conversation", { conversationId })
} }
export async function saveFolderOpenedConversations(
folderId: number,
items: OpenedConversation[]
): Promise<void> {
return getTransport().call("save_folder_opened_conversations", {
folderId,
items,
})
}
export async function setFolderParentBranch( export async function setFolderParentBranch(
path: string, path: string,
parentBranch: string | null parentBranch: string | null
@@ -1053,23 +1069,8 @@ export async function gitAddFiles(
// Window management commands // Window management commands
export async function openFolderWindow( export async function openFolder(path: string): Promise<FolderDetail> {
path: string, return getTransport().call("open_folder", { path })
options?: { newWindow?: boolean }
): Promise<void> {
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 openCommitWindow(folderId: number): Promise<void> { export async function openCommitWindow(folderId: number): Promise<void> {
@@ -1120,8 +1121,10 @@ export async function openProjectBootWindow(source?: string): Promise<void> {
if (getTransport().isDesktop()) { if (getTransport().isDesktop()) {
return getTransport().call("open_project_boot_window", { source }) return getTransport().call("open_project_boot_window", { source })
} }
if (typeof window !== "undefined") {
window.open("/project-boot", "project-boot") window.open("/project-boot", "project-boot")
} }
}
export async function detectPackageManager( export async function detectPackageManager(
name: string name: string
@@ -1145,27 +1148,6 @@ export async function createShadcnProject(params: {
}) })
} }
export async function listOpenFolders(): Promise<FolderHistoryEntry[]> {
return getTransport().call("list_open_folders")
}
export async function focusFolderWindow(folderId: number): Promise<void> {
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 // Conversation CRUD commands
export async function createConversation( export async function createConversation(
+33
View File
@@ -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()
}
+2 -2
View File
@@ -90,9 +90,9 @@ export function buildConversationDraftStorageKey(
} }
export function buildNewConversationDraftStorageKey(params: { export function buildNewConversationDraftStorageKey(params: {
folderId: number tabId: string
}): string { }): string {
return `new:${params.folderId}` return `new:${params.tabId}`
} }
export function loadMessageInputDraft(draftKey: string): string | null { export function loadMessageInputDraft(draftKey: string): string | null {
+55
View File
@@ -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<number, boolean> {
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<number, boolean> = {}
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
const id = Number(k)
if (!Number.isNaN(id) && typeof v === "boolean") {
result[id] = v
}
}
return result
} catch {
return {}
}
}
export function saveFolderExpanded(state: Record<number, boolean>): void {
if (typeof window === "undefined") return
try {
localStorage.setItem(FOLDER_EXPANDED_KEY, JSON.stringify(state))
} catch {
/* ignore */
}
}
+33 -26
View File
@@ -19,7 +19,7 @@ import type {
FolderDetail, FolderDetail,
DbConversationSummary, DbConversationSummary,
ImportResult, ImportResult,
OpenedConversation, OpenedTab,
GitStatusEntry, GitStatusEntry,
GitBranchList, GitBranchList,
GitPullResult, GitPullResult,
@@ -470,22 +470,44 @@ export async function getFolder(folderId: number): Promise<FolderDetail> {
return invoke("get_folder", { folderId }) return invoke("get_folder", { folderId })
} }
export async function listFolderConversations(params: { export async function listAllConversations(params?: {
folder_id: number folder_ids?: number[] | null
agent_type?: AgentType | null agent_type?: AgentType | null
search?: string | null search?: string | null
sort_by?: string | null sort_by?: string | null
status?: string | null status?: string | null
}): Promise<DbConversationSummary[]> { }): Promise<DbConversationSummary[]> {
return invoke("list_folder_conversations", { return invoke("list_all_conversations", {
folderId: params.folder_id, folderIds: params?.folder_ids ?? null,
agentType: params.agent_type ?? null, agentType: params?.agent_type ?? null,
search: params.search ?? null, search: params?.search ?? null,
sortBy: params.sort_by ?? null, sortBy: params?.sort_by ?? null,
status: params.status ?? null, status: params?.status ?? null,
}) })
} }
export async function listOpenedTabs(): Promise<OpenedTab[]> {
return invoke("list_opened_tabs")
}
export async function saveOpenedTabs(items: OpenedTab[]): Promise<void> {
return invoke("save_opened_tabs", { items })
}
export async function listOpenFolderDetails(): Promise<FolderDetail[]> {
return invoke("list_open_folder_details")
}
export async function openFolderById(folderId: number): Promise<FolderDetail> {
return invoke("open_folder_by_id", { folderId })
}
export async function removeFolderFromWorkspace(
folderId: number
): Promise<void> {
return invoke("remove_folder_from_workspace", { folderId })
}
export async function importLocalConversations( export async function importLocalConversations(
folderId: number folderId: number
): Promise<ImportResult> { ): Promise<ImportResult> {
@@ -498,13 +520,6 @@ export async function getFolderConversation(
return invoke("get_folder_conversation", { conversationId }) return invoke("get_folder_conversation", { conversationId })
} }
export async function saveFolderOpenedConversations(
folderId: number,
items: OpenedConversation[]
): Promise<void> {
return invoke("save_folder_opened_conversations", { folderId, items })
}
export async function setFolderParentBranch( export async function setFolderParentBranch(
path: string, path: string,
parentBranch: string | null parentBranch: string | null
@@ -858,8 +873,8 @@ export async function gitAddFiles(
// Window management commands // Window management commands
export async function openFolderWindow(path: string): Promise<void> { export async function openFolder(path: string): Promise<FolderDetail> {
return invoke("open_folder_window", { path }) return invoke("open_folder", { path })
} }
export async function openCommitWindow(folderId: number): Promise<void> { export async function openCommitWindow(folderId: number): Promise<void> {
@@ -888,14 +903,6 @@ export async function openSettingsWindow(
}) })
} }
export async function listOpenFolders(): Promise<FolderHistoryEntry[]> {
return invoke("list_open_folders")
}
export async function focusFolderWindow(folderId: number): Promise<void> {
return invoke("focus_folder_window", { folderId })
}
// Conversation CRUD commands // Conversation CRUD commands
export async function createConversation( export async function createConversation(
+4 -3
View File
@@ -163,11 +163,12 @@ export interface FolderDetail {
parent_branch: string | null parent_branch: string | null
default_agent_type: AgentType | null default_agent_type: AgentType | null
last_opened_at: string last_opened_at: string
opened_conversations: OpenedConversation[]
} }
export interface OpenedConversation { export interface OpenedTab {
conversation_id: number id: number
folder_id: number
conversation_id: number | null
agent_type: AgentType agent_type: AgentType
position: number position: number
is_active: boolean is_active: boolean