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

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

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

View File

@@ -19,15 +19,38 @@ use crate::parsers::{path_eq_for_matching, AgentParser, ParseError};
#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn list_folder_conversations(
pub async fn list_all_conversations(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
folder_ids: Option<Vec<i32>>,
agent_type: Option<AgentType>,
search: Option<String>,
sort_by: Option<String>,
status: Option<String>,
) -> 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
.map_err(AppCommandError::from)
}

View File

@@ -23,7 +23,7 @@ use crate::db::service::folder_service;
use crate::db::AppDatabase;
use crate::models::GitCredentials;
#[cfg(feature = "tauri-runtime")]
use crate::models::{FolderDetail, FolderHistoryEntry, OpenedConversation};
use crate::models::{FolderDetail, FolderHistoryEntry};
use crate::web::event_bridge::EventEmitter;
/// Configure a git command for remote operations:
@@ -526,12 +526,52 @@ pub async fn remove_folder_from_history(
#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn save_folder_opened_conversations(
pub async fn list_open_folder_details(
db: tauri::State<'_, AppDatabase>,
) -> Result<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>,
folder_id: i32,
items: Vec<OpenedConversation>,
) -> Result<(), DbError> {
folder_service::save_opened_conversations(&db.conn, folder_id, items).await
) -> Result<FolderDetail, AppCommandError> {
folder_service::set_folder_open(&db.conn, folder_id, true)
.await
.map_err(AppCommandError::from)?;
folder_service::get_folder_by_id(&db.conn, folder_id)
.await
.map_err(AppCommandError::from)?
.ok_or_else(|| AppCommandError::not_found(format!("Folder {folder_id} not found")))
}
#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn remove_folder_from_workspace(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
) -> Result<(), AppCommandError> {
use crate::db::service::tab_service;
tab_service::delete_tabs_for_folder(&db.conn, folder_id)
.await
.map_err(AppCommandError::from)?;
folder_service::set_folder_open(&db.conn, folder_id, false)
.await
.map_err(AppCommandError::from)
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]

View File

@@ -10,7 +10,7 @@ use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use crate::app_error::AppCommandError;
use crate::db::AppDatabase;
use crate::db::service::app_metadata_service;
use crate::models::FolderHistoryEntry;
use crate::models::FolderDetail;
/// Base traffic-light position (logical px) at 100 % zoom.
#[cfg(target_os = "macos")]
@@ -85,10 +85,6 @@ pub struct CommitWindowState {
owner_by_commit_label: Mutex<HashMap<String, String>>,
}
pub fn folder_window_label(folder_id: i32) -> String {
format!("folder-{folder_id}")
}
/// Detect macOS system dark mode via `defaults read`.
/// Result is cached for the process lifetime via `OnceLock`.
#[cfg(target_os = "macos")]
@@ -288,13 +284,6 @@ impl CommitWindowState {
}
}
fn get_folder_id_from_window(window: &tauri::WebviewWindow) -> Option<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 {
match section {
Some("appearance") => "settings/appearance",
@@ -331,103 +320,36 @@ fn resolve_settings_target(section: Option<&str>, agent_type: Option<&str>) -> S
route.to_string()
}
#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn list_open_folders(
app: AppHandle,
db: tauri::State<'_, AppDatabase>,
) -> Result<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_attr(feature = "tauri-runtime", tauri::command)]
pub async fn open_folder_window(
app: AppHandle,
db: tauri::State<'_, AppDatabase>,
path: String,
) -> Result<(), AppCommandError> {
// Add to history via DB
) -> Result<FolderDetail, AppCommandError> {
// Single-window workspace: upsert the folder (is_open = true), close any
// legacy project-boot window, and return the full detail for the frontend
// to add to its workspace state.
let entry = crate::db::service::folder_service::add_folder(&db.conn, &path)
.await
.map_err(AppCommandError::from)?;
let label = folder_window_label(entry.id);
if let Some(existing) = app.get_webview_window(&label) {
post_window_setup(&existing);
let _ = existing.unminimize();
existing
.set_focus()
.map_err(|e| AppCommandError::window("Failed to focus folder window", e.to_string()))?;
if let Some(w) = app.get_webview_window("welcome") {
let _ = w.close();
}
if let Some(w) = app.get_webview_window("project-boot") {
let _ = w.close();
}
return Ok(());
}
let url = WebviewUrl::App(format!("folder?id={}", entry.id).into());
let builder = WebviewWindowBuilder::new(&app, &label, url)
.title(&entry.name)
.inner_size(1260.0, 860.0)
.min_inner_size(900.0, 600.0);
let folder_window = apply_platform_window_style(builder)
.build()
.map_err(|e| AppCommandError::window("Failed to open folder window", e.to_string()))?;
post_window_setup(&folder_window);
// Close welcome and project-boot windows
if let Some(w) = app.get_webview_window("welcome") {
let _ = w.close();
}
if let Some(w) = app.get_webview_window("project-boot") {
let _ = w.close();
}
Ok(())
let folder = crate::db::service::folder_service::get_folder_by_id(&db.conn, entry.id)
.await
.map_err(AppCommandError::from)?
.ok_or_else(|| AppCommandError::not_found("Folder not found after add"))?;
// Bring the main window to focus if it exists
if let Some(main) = app.get_webview_window("main") {
let _ = main.unminimize();
let _ = main.set_focus();
}
Ok(folder)
}
#[cfg(feature = "tauri-runtime")]
@@ -714,24 +636,6 @@ pub async fn cleanup_dangling_merge(app: &AppHandle, merge_window_label: &str) {
}
}
pub fn open_welcome_window(app: &AppHandle) -> Result<(), AppCommandError> {
if let Some(existing) = app.get_webview_window("welcome") {
post_window_setup(&existing);
return Ok(());
}
let url = WebviewUrl::App("welcome".into());
let builder = WebviewWindowBuilder::new(app, "welcome", url)
.title("Codeg")
.inner_size(800.0, 520.0)
.min_inner_size(600.0, 400.0)
.center();
let welcome_window = apply_platform_window_style(builder)
.build()
.map_err(|e| AppCommandError::window("Failed to open welcome window", e.to_string()))?;
post_window_setup(&welcome_window);
Ok(())
}
#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn open_stash_window(
@@ -818,18 +722,13 @@ pub async fn open_project_boot_window(
app: AppHandle,
source: Option<String>,
) -> Result<(), AppCommandError> {
let _ = source;
if let Some(existing) = app.get_webview_window("project-boot") {
post_window_setup(&existing);
let _ = existing.unminimize();
existing.set_focus().map_err(|e| {
AppCommandError::window("Failed to focus project boot window", e.to_string())
})?;
// Close welcome if opened from welcome
if source.as_deref() == Some("welcome") {
if let Some(w) = app.get_webview_window("welcome") {
let _ = w.close();
}
}
return Ok(());
}
@@ -846,13 +745,6 @@ pub async fn open_project_boot_window(
})?;
post_window_setup(&window);
// Close welcome if opened from welcome
if source.as_deref() == Some("welcome") {
if let Some(w) = app.get_webview_window("welcome") {
let _ = w.close();
}
}
Ok(())
}