From 388f92637ca9c9738f8b978d628e9ff698d26640 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 25 Mar 2026 18:24:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96folder=E6=89=93=E5=BC=80?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/folder_commands.rs | 2 +- src-tauri/src/web/handlers/folder_commands.rs | 45 +++++++++++++++++-- src-tauri/src/web/handlers/folders.rs | 21 +++++++++ src-tauri/src/web/router.rs | 3 ++ src/components/layout/branch-dropdown.tsx | 2 +- .../layout/folder-name-dropdown.tsx | 3 +- src/components/layout/folder-title-bar.tsx | 2 +- src/contexts/folder-context.tsx | 19 +++++++- src/lib/api.ts | 35 ++++++++++++--- 9 files changed, 119 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/commands/folder_commands.rs b/src-tauri/src/commands/folder_commands.rs index 08581a6..3656dcf 100644 --- a/src-tauri/src/commands/folder_commands.rs +++ b/src-tauri/src/commands/folder_commands.rs @@ -7,7 +7,7 @@ use tokio::sync::Mutex; static BOOTSTRAP_FOLDER_COMMANDS_LOCK: Mutex<()> = Mutex::const_new(()); -fn load_package_scripts_as_commands(folder_path: &str) -> Vec<(String, String)> { +pub(crate) fn load_package_scripts_as_commands(folder_path: &str) -> Vec<(String, String)> { let mut has_package_json = false; let mut has_pnpm_lock = false; let mut has_yarn_lock = false; diff --git a/src-tauri/src/web/handlers/folder_commands.rs b/src-tauri/src/web/handlers/folder_commands.rs index d919df6..df375a6 100644 --- a/src-tauri/src/web/handlers/folder_commands.rs +++ b/src-tauri/src/web/handlers/folder_commands.rs @@ -117,6 +117,45 @@ pub async fn reorder_folder_commands( Ok(Json(())) } -// TODO: bootstrap_folder_commands_from_package_json — requires access to -// `load_package_scripts_as_commands` which is private in commands/folder_commands.rs. -// Make it pub(crate) first, then add the web handler here. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootstrapFolderCommandsParams { + pub folder_id: i32, + pub folder_path: String, +} + +pub async fn bootstrap_folder_commands_from_package_json( + Extension(app): Extension, + Json(params): Json, +) -> Result>, AppCommandError> { + let db = app.state::(); + + let existing = folder_command_service::list_by_folder(&db.conn, params.folder_id) + .await + .map_err(AppCommandError::from)?; + if !existing.is_empty() { + return Ok(Json(existing)); + } + + let commands_to_create = tokio::task::spawn_blocking(move || { + crate::commands::folder_commands::load_package_scripts_as_commands(¶ms.folder_path) + }) + .await + .map_err(|e| AppCommandError::new( + crate::app_error::AppErrorCode::TaskExecutionFailed, + format!("bootstrap task failed: {e}"), + ))?; + + if commands_to_create.is_empty() { + return Ok(Json(existing)); + } + + folder_command_service::create_many(&db.conn, params.folder_id, &commands_to_create) + .await + .map_err(AppCommandError::from)?; + + let result = folder_command_service::list_by_folder(&db.conn, params.folder_id) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} diff --git a/src-tauri/src/web/handlers/folders.rs b/src-tauri/src/web/handlers/folders.rs index 2a4f54a..2ebe3a5 100644 --- a/src-tauri/src/web/handlers/folders.rs +++ b/src-tauri/src/web/handlers/folders.rs @@ -24,6 +24,16 @@ pub async fn load_folder_history( Ok(Json(result)) } +pub async fn list_open_folders( + Extension(app): Extension, +) -> Result>, AppCommandError> { + let db = app.state::(); + let result = folder_service::list_open_folders(&db.conn) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + pub async fn get_folder( Extension(app): Extension, Json(params): Json, @@ -55,6 +65,17 @@ pub async fn open_folder_window( Ok(Json(entry)) } +pub async fn close_folder_window( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let db = app.state::(); + folder_service::set_folder_open(&db.conn, params.folder_id, false) + .await + .map_err(AppCommandError::from)?; + Ok(Json(())) +} + // --- New handlers below --- #[derive(Deserialize)] diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index fc02e1e..000177b 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -37,6 +37,8 @@ pub fn build_router(app: tauri::AppHandle, token: String, static_dir: std::path: .route("/update_conversation_external_id", post(handlers::conversations::update_conversation_external_id)) // ─── Folders ─── .route("/load_folder_history", post(handlers::folders::load_folder_history)) + .route("/list_open_folders", post(handlers::folders::list_open_folders)) + .route("/close_folder_window", post(handlers::folders::close_folder_window)) .route("/get_folder", post(handlers::folders::get_folder)) .route("/open_folder_window", post(handlers::folders::open_folder_window)) .route("/add_folder_to_history", post(handlers::folders::add_folder_to_history)) @@ -115,6 +117,7 @@ pub fn build_router(app: tauri::AppHandle, token: String, static_dir: std::path: .route("/update_folder_command", post(handlers::folder_commands::update_folder_command)) .route("/delete_folder_command", post(handlers::folder_commands::delete_folder_command)) .route("/reorder_folder_commands", post(handlers::folder_commands::reorder_folder_commands)) + .route("/bootstrap_folder_commands_from_package_json", post(handlers::folder_commands::bootstrap_folder_commands_from_package_json)) // ─── MCP ─── .route("/mcp_scan_local", post(handlers::mcp::mcp_scan_local)) .route("/mcp_list_marketplaces", post(handlers::mcp::mcp_list_marketplaces)) diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx index a304622..134c8a5 100644 --- a/src/components/layout/branch-dropdown.tsx +++ b/src/components/layout/branch-dropdown.tsx @@ -345,7 +345,7 @@ export function BranchDropdown({ setWorktreeOpen(false) await runGitTask(t("tasks.newWorktree", { name }), async () => { await gitWorktreeAdd(folderPath, name, wtPath) - await openFolderWindow(wtPath) + await openFolderWindow(wtPath, { newWindow: true }) await setFolderParentBranch(wtPath, branch) }) } diff --git a/src/components/layout/folder-name-dropdown.tsx b/src/components/layout/folder-name-dropdown.tsx index 374a646..23c7391 100644 --- a/src/components/layout/folder-name-dropdown.tsx +++ b/src/components/layout/folder-name-dropdown.tsx @@ -54,13 +54,14 @@ export function FolderNameDropdown() { if (selected) { await openFolderWindow( Array.isArray(selected) ? selected[0] : selected, + { newWindow: true }, ) } } async function handleSelect(path: string) { try { - await openFolderWindow(path) + await openFolderWindow(path, { newWindow: true }) } catch { // ignore } diff --git a/src/components/layout/folder-title-bar.tsx b/src/components/layout/folder-title-bar.tsx index 73971f9..1ab16da 100644 --- a/src/components/layout/folder-title-bar.tsx +++ b/src/components/layout/folder-title-bar.tsx @@ -82,7 +82,7 @@ export function FolderTitleBar() { const result = await openFileDialog({ directory: true, multiple: false }) if (!result) return const selected = Array.isArray(result) ? result[0] : result - await openFolderWindow(selected) + await openFolderWindow(selected, { newWindow: true }) } catch (err) { console.error("[FolderTitleBar] failed to open folder:", err) } diff --git a/src/contexts/folder-context.tsx b/src/contexts/folder-context.tsx index 282cb31..76665e0 100644 --- a/src/contexts/folder-context.tsx +++ b/src/contexts/folder-context.tsx @@ -11,7 +11,8 @@ import { type ReactNode, } from "react" import { toErrorMessage } from "@/lib/app-error" -import { getFolder, listFolderConversations } from "@/lib/api" +import { getFolder, listFolderConversations, closeFolderWindow } from "@/lib/api" +import { isDesktop } from "@/lib/transport" import type { AgentType, AgentStats, @@ -195,6 +196,22 @@ export function FolderProvider({ } }, []) + // Web mode: register this tab's name so that window.open(url, "folder-{id}") + // from other pages can find and reuse it instead of opening duplicates. + // Also notify backend when the folder tab closes. + useEffect(() => { + if (isDesktop() || !folderId) return + + window.name = `folder-${folderId}` + + const onUnload = () => closeFolderWindow(folderId) + window.addEventListener("pagehide", onUnload) + + return () => { + window.removeEventListener("pagehide", onUnload) + } + }, [folderId]) + const selectConversation = useCallback((id: number, agentType: AgentType) => { setSelectedConversation({ id, agentType }) setNewConversation(null) diff --git a/src/lib/api.ts b/src/lib/api.ts index 214fc57..d968830 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -839,16 +839,23 @@ export async function gitAddFiles( // Window management commands -export async function openFolderWindow(path: string): Promise { +export async function openFolderWindow( + path: string, + options?: { newWindow?: boolean }, +): Promise { if (getTransport().isDesktop()) { return getTransport().call("open_folder_window", { path }) } - // Web mode: add folder to DB and navigate to folder page const entry = await getTransport().call<{ id: number }>( "open_folder_window", - { path } + { path }, ) - window.location.href = `/folder?id=${entry.id}` + 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 { @@ -900,7 +907,25 @@ export async function listOpenFolders(): Promise { } export async function focusFolderWindow(folderId: number): Promise { - return getTransport().call("focus_folder_window", { folderId }) + if (getTransport().isDesktop()) { + return getTransport().call("focus_folder_window", { folderId }) + } + // Web mode: use named window — reuses existing tab if still open, + // otherwise opens a new one. + window.open(`/folder?id=${folderId}`, `folder-${folderId}`) +} + +/** + * Notify the backend that a folder tab has been closed. + * Uses sendBeacon for reliability during page unload. + */ +export function closeFolderWindow(folderId: number): void { + if (getTransport().isDesktop()) return + const token = localStorage.getItem("codeg_token") ?? "" + navigator.sendBeacon( + `/api/close_folder_window?token=${encodeURIComponent(token)}`, + JSON.stringify({ folderId }), + ) } // Conversation CRUD commands