From 4691e75692ac3339b17986d3b49085d4b3603386 Mon Sep 17 00:00:00 2001 From: GCWing Date: Sat, 28 Mar 2026 18:10:05 +0800 Subject: [PATCH] feat(workspace): welcome recent list, remove-from-recent, RPC and core support --- src/apps/desktop/src/api/commands.rs | 18 +++ src/apps/desktop/src/lib.rs | 1 + src/apps/server/src/rpc_dispatcher.rs | 10 ++ .../core/src/service/workspace/manager.rs | 16 +++ .../core/src/service/workspace/service.rs | 12 ++ .../src/app/scenes/welcome/WelcomeScene.scss | 124 +++++++++++++++--- .../src/app/scenes/welcome/WelcomeScene.tsx | 60 ++++++--- .../api/service-api/GlobalAPI.ts | 10 ++ .../contexts/WorkspaceContext.tsx | 8 ++ .../services/business/workspaceManager.ts | 8 +- src/web-ui/src/locales/en-US/common.json | 1 + src/web-ui/src/locales/zh-CN/common.json | 1 + src/web-ui/src/shared/types/global-state.ts | 5 + 13 files changed, 239 insertions(+), 35 deletions(-) diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index c352cd0c..899f1c2a 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -102,6 +102,12 @@ pub struct ResetAssistantWorkspaceRequest { pub workspace_id: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveRecentWorkspaceRequest { + pub workspace_id: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReorderOpenedWorkspacesRequest { @@ -1240,6 +1246,18 @@ pub async fn get_recent_workspaces( .collect()) } +#[tauri::command] +pub async fn remove_recent_workspace( + state: State<'_, AppState>, + request: RemoveRecentWorkspaceRequest, +) -> Result<(), String> { + state + .workspace_service + .remove_workspace_from_recent(&request.workspace_id) + .await + .map_err(|e| format!("Failed to remove workspace from recent: {}", e)) +} + #[tauri::command] pub async fn cleanup_invalid_workspaces( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 2ee6beff..78a676eb 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -575,6 +575,7 @@ pub async fn run() { subscribe_config_updates, get_model_configs, get_recent_workspaces, + remove_recent_workspace, cleanup_invalid_workspaces, get_opened_workspaces, open_workspace, diff --git a/src/apps/server/src/rpc_dispatcher.rs b/src/apps/server/src/rpc_dispatcher.rs index 6cb9f443..2b26778f 100644 --- a/src/apps/server/src/rpc_dispatcher.rs +++ b/src/apps/server/src/rpc_dispatcher.rs @@ -69,6 +69,16 @@ pub async fn dispatch( let list = state.workspace_service.get_recent_workspaces().await; Ok(serde_json::to_value(&list).unwrap_or_default()) } + "remove_recent_workspace" => { + let request = extract_request(¶ms)?; + let workspace_id = get_string(request, "workspaceId")?; + state + .workspace_service + .remove_workspace_from_recent(&workspace_id) + .await + .map_err(|e| anyhow!("{}", e))?; + Ok(serde_json::Value::Null) + } "get_opened_workspaces" => { let list = state.workspace_service.get_opened_workspaces().await; Ok(serde_json::to_value(&list).unwrap_or_default()) diff --git a/src/crates/core/src/service/workspace/manager.rs b/src/crates/core/src/service/workspace/manager.rs index 035fc1f5..76642812 100644 --- a/src/crates/core/src/service/workspace/manager.rs +++ b/src/crates/core/src/service/workspace/manager.rs @@ -1352,6 +1352,22 @@ impl WorkspaceManager { .collect(); } + /// Removes a workspace id from recent lists only (does not unregister the workspace). + pub fn remove_from_recent_workspaces_only(&mut self, workspace_id: &str) -> bool { + let mut changed = false; + let before = self.recent_workspaces.len(); + self.recent_workspaces.retain(|id| id != workspace_id); + if self.recent_workspaces.len() != before { + changed = true; + } + let before_a = self.recent_assistant_workspaces.len(); + self.recent_assistant_workspaces.retain(|id| id != workspace_id); + if self.recent_assistant_workspaces.len() != before_a { + changed = true; + } + changed + } + /// Returns a reference to the recent-workspaces list. pub fn get_recent_workspaces(&self) -> &Vec { &self.recent_workspaces diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index 0f17ed66..0feb5830 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -518,6 +518,18 @@ impl WorkspaceService { recent_workspaces } + /// Drops a workspace from recent lists only (workspace record and open state unchanged). + pub async fn remove_workspace_from_recent(&self, workspace_id: &str) -> BitFunResult<()> { + let changed = { + let mut manager = self.manager.write().await; + manager.remove_from_recent_workspaces_only(workspace_id) + }; + if changed { + self.save_workspace_data().await?; + } + Ok(()) + } + /// Searches workspaces. pub async fn search_workspaces(&self, query: &str) -> Vec { let manager = self.manager.read().await; diff --git a/src/web-ui/src/app/scenes/welcome/WelcomeScene.scss b/src/web-ui/src/app/scenes/welcome/WelcomeScene.scss index 82bc9cdb..fe5385cc 100644 --- a/src/web-ui/src/app/scenes/welcome/WelcomeScene.scss +++ b/src/web-ui/src/app/scenes/welcome/WelcomeScene.scss @@ -124,27 +124,55 @@ gap: 1px; } + &__recent-row { + display: flex; + align-items: stretch; + border-radius: $size-radius-sm; + overflow: hidden; + transition: background $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-soft); + + .welcome-scene__recent-item { + color: var(--color-text-primary); + + svg { color: var(--color-text-secondary); } + } + + .welcome-scene__recent-time-btn__label { + opacity: 0; + } + + .welcome-scene__recent-time-btn__icon { + opacity: 1; + color: var(--color-text-secondary); + } + } + + &:hover .welcome-scene__recent-time-btn:hover { + background: var(--element-bg-muted); + + .welcome-scene__recent-time-btn__icon svg { + transform: scale(1.12); + } + } + } + &__recent-item { display: flex; align-items: center; gap: $size-gap-2; + flex: 1; + min-width: 0; padding: $size-gap-1 + 2px $size-gap-2; border: none; - border-radius: $size-radius-sm; background: transparent; color: var(--color-text-secondary); font-size: $font-size-base; cursor: pointer; text-align: left; - transition: background $motion-fast $easing-standard, - color $motion-fast $easing-standard; - - &:hover { - background: var(--element-bg-soft); - color: var(--color-text-primary); - - svg { color: var(--color-text-secondary); } - } + transition: color $motion-fast $easing-standard; svg { flex-shrink: 0; @@ -153,6 +181,67 @@ } } + &__recent-time-btn { + position: relative; + flex-shrink: 0; + align-self: stretch; + min-width: 5.25rem; + margin: 0; + padding: 0 $size-gap-2 0 $size-gap-1; + border: none; + background: transparent; + color: var(--color-text-muted); + font-size: $font-size-sm; + cursor: pointer; + transition: color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + &__label { + display: flex; + align-items: center; + justify-content: flex-end; + min-height: 100%; + padding-right: 1px; + transition: opacity $motion-fast $easing-standard; + } + + &__icon { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: $size-gap-2; + opacity: 0; + color: var(--color-text-muted); + pointer-events: none; + transition: opacity $motion-fast $easing-standard, + color $motion-fast $easing-standard; + + svg { + display: block; + transform-origin: center right; + transition: transform $motion-fast $easing-standard; + } + } + + &:focus-visible { + color: var(--color-text-primary); + outline: 2px solid var(--focus-ring-color, var(--color-accent)); + outline-offset: -2px; + z-index: 1; + } + + &:focus-visible &__label { + opacity: 0; + } + + &:focus-visible &__icon { + opacity: 1; + color: var(--color-text-secondary); + } + } + &__recent-name { flex: 1; display: inline-flex; @@ -180,12 +269,6 @@ font-size: $font-size-sm; } - &__recent-time { - flex-shrink: 0; - font-size: $font-size-sm; - color: var(--color-text-muted); - } - &__no-recent { font-size: $font-size-xs; color: var(--color-text-muted); @@ -221,8 +304,17 @@ animation: none; &__recent-item, + &__recent-time-btn, &__link-btn { transition: none; } + + &__recent-time-btn__icon svg { + transition: none; + } + } + + .welcome-scene__recent-row:hover .welcome-scene__recent-time-btn:hover .welcome-scene__recent-time-btn__icon svg { + transform: none; } } diff --git a/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx b/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx index 6d0f015c..ca5ac675 100644 --- a/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx +++ b/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx @@ -8,7 +8,7 @@ import React, { useState, useCallback, useMemo } from 'react'; import { - FolderOpen, Clock, FolderPlus, + FolderOpen, Clock, FolderPlus, Trash2, } from 'lucide-react'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { useSceneStore } from '@/app/stores/sceneStore'; @@ -26,7 +26,7 @@ const WelcomeScene: React.FC = () => { const { t } = useI18n('common'); const { hasWorkspace, currentWorkspace, recentWorkspaces, - openWorkspace, switchWorkspace, + openWorkspace, switchWorkspace, removeWorkspaceFromRecent, } = useWorkspaceContext(); const openScene = useSceneStore(s => s.openScene); const [isSelecting, setIsSelecting] = useState(false); @@ -85,6 +85,14 @@ const WelcomeScene: React.FC = () => { } }, [switchWorkspace, openScene]); + const handleRemoveFromRecent = useCallback(async (workspaceId: string) => { + try { + await removeWorkspaceFromRecent(workspaceId); + } catch (e) { + log.error('Failed to remove workspace from recent', e); + } + }, [removeWorkspaceFromRecent]); + const formatDate = useCallback((dateString: string) => { try { const date = new Date(dateString); @@ -137,26 +145,42 @@ const WelcomeScene: React.FC = () => { {displayRecentWorkspaces.map(ws => { const { hostPrefix, folderLabel, tooltip } = getRecentWorkspaceLineParts(ws); return ( - +
+ + + - +
); })} diff --git a/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts b/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts index 2b758760..6ccd862a 100644 --- a/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts @@ -256,6 +256,16 @@ export class GlobalAPI { } } + async removeRecentWorkspace(workspaceId: string): Promise { + try { + await api.invoke('remove_recent_workspace', { + request: { workspaceId }, + }); + } catch (error) { + throw createTauriCommandError('remove_recent_workspace', error, { workspaceId }); + } + } + async cleanupInvalidWorkspaces(): Promise { try { return await api.invoke('cleanup_invalid_workspaces'); diff --git a/src/web-ui/src/infrastructure/contexts/WorkspaceContext.tsx b/src/web-ui/src/infrastructure/contexts/WorkspaceContext.tsx index 40f745ad..a3b64986 100644 --- a/src/web-ui/src/infrastructure/contexts/WorkspaceContext.tsx +++ b/src/web-ui/src/infrastructure/contexts/WorkspaceContext.tsx @@ -40,6 +40,7 @@ interface WorkspaceContextValue extends WorkspaceState { ) => Promise; scanWorkspaceInfo: () => Promise; refreshRecentWorkspaces: () => Promise; + removeWorkspaceFromRecent: (workspaceId: string) => Promise; hasWorkspace: boolean; workspaceName: string; workspacePath: string; @@ -158,6 +159,10 @@ export const WorkspaceProvider: React.FC = ({ children } return await workspaceManager.refreshRecentWorkspaces(); }, []); + const removeWorkspaceFromRecent = useCallback(async (workspaceId: string): Promise => { + return await workspaceManager.removeWorkspaceFromRecent(workspaceId); + }, []); + const activeWorkspace = state.currentWorkspace; const openedWorkspacesList = useMemo( () => Array.from(state.openedWorkspaces.values()), @@ -198,6 +203,7 @@ export const WorkspaceProvider: React.FC = ({ children } reorderOpenedWorkspacesInSection, scanWorkspaceInfo, refreshRecentWorkspaces, + removeWorkspaceFromRecent, hasWorkspace, workspaceName, workspacePath, @@ -254,6 +260,8 @@ export const useWorkspaceEvents = ( case 'workspace:updated': onWorkspaceUpdated?.(event.workspace); break; + case 'workspace:recent-updated': + break; } }); diff --git a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts index c9461ef3..75a2a0e3 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts @@ -27,6 +27,7 @@ export type WorkspaceEvent = | { type: 'workspace:switched'; workspace: WorkspaceInfo } | { type: 'workspace:active-changed'; workspace: WorkspaceInfo | null } | { type: 'workspace:updated'; workspace: WorkspaceInfo } + | { type: 'workspace:recent-updated' } | { type: 'workspace:loading'; loading: boolean } | { type: 'workspace:error'; error: string | null }; @@ -827,13 +828,18 @@ class WorkspaceManager { public async refreshRecentWorkspaces(): Promise { try { const recentWorkspaces = await globalStateAPI.getRecentWorkspaces(); - this.updateState({ recentWorkspaces }); + this.updateState({ recentWorkspaces }, { type: 'workspace:recent-updated' }); log.debug('Recent workspaces refreshed', { count: recentWorkspaces.length }); } catch (error) { log.error('Failed to refresh recent workspaces', { error }); } } + public async removeWorkspaceFromRecent(workspaceId: string): Promise { + await globalStateAPI.removeWorkspaceFromRecent(workspaceId); + await this.refreshRecentWorkspaces(); + } + public async cleanupInvalidWorkspaces(): Promise { try { const removedCount = await globalStateAPI.cleanupInvalidWorkspaces(); diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 59a22bda..4e30f7c3 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -845,6 +845,7 @@ "comingSoon": "Coming Soon", "recentWorkspaces": "Switch Workspace", "openOtherProject": "Open", + "removeFromRecent": "Remove from recent", "newProject": "New", "noOtherWorkspaces": "No other workspaces", "emptyHint": "Pick a scene and let's build something great", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 31ed7f84..99334cf0 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -845,6 +845,7 @@ "comingSoon": "即将推出", "recentWorkspaces": "切换工作区", "openOtherProject": "打开", + "removeFromRecent": "从最近列表移除", "newProject": "新建", "noOtherWorkspaces": "暂无其他工作区", "emptyHint": "选一个场景,开始今天的创作吧", diff --git a/src/web-ui/src/shared/types/global-state.ts b/src/web-ui/src/shared/types/global-state.ts index 1affca8d..b580412b 100644 --- a/src/web-ui/src/shared/types/global-state.ts +++ b/src/web-ui/src/shared/types/global-state.ts @@ -184,6 +184,7 @@ export interface GlobalStateAPI { getCurrentWorkspace(): Promise; getOpenedWorkspaces(): Promise; getRecentWorkspaces(): Promise; + removeWorkspaceFromRecent(workspaceId: string): Promise; cleanupInvalidWorkspaces(): Promise; scanWorkspaceInfo(workspacePath: string): Promise; @@ -403,6 +404,10 @@ export function createGlobalStateAPI(): GlobalStateAPI { return workspaces; }, + async removeWorkspaceFromRecent(workspaceId: string): Promise { + await globalAPI.removeRecentWorkspace(workspaceId); + }, + async cleanupInvalidWorkspaces(): Promise { return await globalAPI.cleanupInvalidWorkspaces(); },