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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user