From 1749ab656999135a2855ece2152f3dddbe651a6d Mon Sep 17 00:00:00 2001 From: deadwavewave Date: Sun, 12 Jul 2026 14:54:32 +0800 Subject: [PATCH 1/3] [codex] Refine contextual project and space popovers --- docs/ui/WINDOW_UI_STANDARD.md | 18 +- .../components/AnchoredOperationPopover.tsx | 74 +++ src/app/renderer/components/CoveSelect.tsx | 8 + .../components/TransientLayerOwnerContext.tsx | 27 + .../components/ViewportMenuSurface.tsx | 27 +- src/app/renderer/i18n/locales/en.worktree.ts | 2 + .../renderer/i18n/locales/zh-CN.worktree.ts | 2 + src/app/renderer/shell/AppShell.tsx | 18 +- .../components/AddProjectWizardWindow.tsx | 514 ++++++------------ .../shell/components/AppShellPopups.tsx | 5 +- src/app/renderer/shell/components/Sidebar.tsx | 2 +- .../shell/components/SidebarToolbar.tsx | 10 +- .../useAddProjectWizardCreateProject.ts | 300 +++++----- .../shell/hooks/useAddProjectWizardRequest.ts | 19 + .../shell/hooks/useShellOverlayState.ts | 22 +- src/app/renderer/styles.css | 3 + .../styles/anchored-operation-popover.css | 60 ++ .../styles/workspace-operation-popovers.css | 485 +++++++++++++++++ .../workspace-space-operation-state.css | 66 +++ .../workspaceCanvas/WorkspaceCanvasView.tsx | 9 +- .../WorkspaceCanvasView.types.ts | 12 +- .../useSpaceOwnership.worktreeWarning.ts | 1 + .../workspaceCanvas/hooks/useSpaceUi.ts | 84 ++- .../hooks/useSpaceWorktreeBusyLabels.ts | 26 + .../hooks/useSpaces.createSpaceSelection.ts | 16 + .../components/workspaceCanvas/types.ts | 9 +- .../view/WorkspaceCanvasMenus.tsx | 8 +- .../view/WorkspaceCanvasWindows.tsx | 50 +- .../view/WorkspaceSpaceActionMenu.tsx | 14 +- .../view/WorkspaceSpaceBranchRenameDialog.tsx | 30 +- .../WorkspaceSpaceExplorerOverlay.keyboard.ts | 2 +- ...WorkspaceSpaceExplorerOverlay.mutations.ts | 27 +- ...orkspaceSpaceExplorerOverlay.operations.ts | 1 + ...rkspaceSpaceExplorerOverlayContextMenu.tsx | 12 +- .../WorkspaceSpaceExplorerOverlayWindows.tsx | 42 +- .../view/WorkspaceSpaceRegion.tsx | 5 + .../view/WorkspaceSpaceRegionItem.tsx | 30 +- .../view/WorkspaceSpaceRegionsOverlay.tsx | 10 +- .../windows/SpaceTargetMountPickerWindow.tsx | 51 +- ...SpaceWorktreeMismatchDropWarningWindow.tsx | 75 +-- .../windows/SpaceWorktreeGuardWindow.tsx | 68 ++- .../renderer/windows/SpaceWorktreeWindow.tsx | 131 ++--- .../windows/SpaceWorktreeWindow.types.ts | 33 ++ .../windows/SpaceWorktreeWindowDialog.tsx | 30 +- .../windows/useSpaceWorktreeArchiveState.ts | 34 +- .../windows/useSpaceWorktreeGuardActions.ts | 20 +- ...endpoints-mounts.addProjectWizard.steps.ts | 63 ++- ...local-worktree.pr-chip.integration.spec.ts | 1 + tests/e2e/remote-ui-audit.spec.ts | 11 +- ...workspace-canvas.worktree-branches.spec.ts | 144 ++++- ...canvas.worktree-create.light-theme.spec.ts | 40 +- .../unit/app/addProjectWizardWindow.spec.tsx | 64 +++ .../app/anchoredOperationPopover.spec.tsx | 110 ++++ .../contexts/spaceAnchoredWindows.spec.tsx | 96 ++++ .../spaceWorktreeWindow.archive.spec.tsx | 5 +- .../spaceWorktreeWindow.create-close.spec.tsx | 59 ++ .../spaceWorktreeWindow.renderUtils.tsx | 10 +- .../workspaceCanvas.globalDismissals.spec.tsx | 3 +- .../workspaceCanvasSpaceUi.web.spec.tsx | 53 ++ .../workspaceSpaceActionMenu.spec.tsx | 32 +- .../workspaceSpaceSwitcher.menu.spec.tsx | 60 +- 61 files changed, 2383 insertions(+), 860 deletions(-) create mode 100644 src/app/renderer/components/AnchoredOperationPopover.tsx create mode 100644 src/app/renderer/components/TransientLayerOwnerContext.tsx create mode 100644 src/app/renderer/shell/hooks/useAddProjectWizardRequest.ts create mode 100644 src/app/renderer/styles/anchored-operation-popover.css create mode 100644 src/app/renderer/styles/workspace-operation-popovers.css create mode 100644 src/app/renderer/styles/workspace-space-operation-state.css create mode 100644 src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceWorktreeBusyLabels.ts create mode 100644 src/contexts/worktree/presentation/renderer/windows/SpaceWorktreeWindow.types.ts create mode 100644 tests/unit/app/addProjectWizardWindow.spec.tsx create mode 100644 tests/unit/app/anchoredOperationPopover.spec.tsx create mode 100644 tests/unit/contexts/spaceAnchoredWindows.spec.tsx diff --git a/docs/ui/WINDOW_UI_STANDARD.md b/docs/ui/WINDOW_UI_STANDARD.md index 62c288f57..27e191330 100644 --- a/docs/ui/WINDOW_UI_STANDARD.md +++ b/docs/ui/WINDOW_UI_STANDARD.md @@ -15,6 +15,17 @@ - Task Delete Confirm - Agent Launcher - Settings Panel(作为大型窗口面板) +- 由右键菜单、工具栏按钮触发的上下文操作浮层 + +### 1.1 先选择正确的容器 + +- **上下文操作浮层(Popover)**:重命名、创建 Worktree、归档、挂载选择、删除确认等短流程,应锚定触发项附近;提交前点击外部或按 Esc 关闭,不遮挡整个应用。 +- **局部异步状态**:提交后立即收起浮层;由操作所属的 Space 投射轻量磨砂忙碌状态,只锁定目标 Space,其余 Space 和应用区域保持可操作。 +- **模态窗口(Modal)**:仅用于必须打断全局流程的决策,或无法在小浮层中清晰完成的复杂管理任务。 + +状态所有权不得放在会因点击外部而卸载的 Popover 内。异步操作必须由更长生命周期的 owner 持有,并保证:同一操作只能完成一次、错误可以重新进入上下文修复、卸载后不提交过期 UI 状态。 + +Popover 内的 Select、日期选择等 Portal 子层必须登记为同一 transient layer owner:点击子层不得关闭父层,Esc 只关闭最上层。两个完整 surface 之间应使用状态接力,禁止在同一锚点重叠显示。 ## 2) 统一视觉 Token @@ -25,6 +36,7 @@ ### 2.2 Surface - 16px 圆角 - 边框/底色/阴影必须跟随主题 token(例如 `--cove-window-surface`、`--cove-surface*`、`--cove-border*`、`--cove-shadow-color-*`) +- 上下文操作浮层使用更紧凑的圆角、内边距与菜单同源的材质,避免呈现为缩小版模态框。 ### 2.3 Input - 10px 圆角 @@ -39,10 +51,11 @@ ## 3) 交互一致性 -- 点击遮罩关闭窗口(危险动作弹窗除外时需二次确认) +- 模态窗口点击遮罩关闭;上下文操作浮层点击外部或按 Esc 关闭(提交进行中除外) - 主按钮始终放最右 - 错误信息用统一 error block 样式 - 小窗优先简洁,复杂编辑由完整弹窗承载 +- 动效应短促且只表达空间关系;必须支持 `prefers-reduced-motion`,不得用动效延迟可操作状态 ## 4) 实现约定(当前) @@ -66,3 +79,6 @@ - 是否使用统一按钮语义(ghost/secondary/primary/danger) - 是否提供稳定 `data-testid` - 是否保证键盘与关闭行为一致(Esc / blur / confirm) +- 是否根据任务范围正确选择 Popover、局部忙碌状态或 Modal +- 是否让异步 owner 独立于浮层生命周期,并将忙碌状态限制在目标 Space +- 是否验证亮色、暗色与减少动态效果模式 diff --git a/src/app/renderer/components/AnchoredOperationPopover.tsx b/src/app/renderer/components/AnchoredOperationPopover.tsx new file mode 100644 index 000000000..f3c03773a --- /dev/null +++ b/src/app/renderer/components/AnchoredOperationPopover.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { ViewportMenuSurface } from './ViewportMenuSurface' +import { + targetBelongsToTransientLayer, + TransientLayerOwnerProvider, +} from './TransientLayerOwnerContext' + +export interface AnchoredOperationPopoverAnchor { + x: number + y: number +} + +export function AnchoredOperationPopover({ + anchor, + ariaLabel, + children, + className, + dismissDisabled = false, + estimatedHeight = 360, + estimatedWidth = 360, + onDismiss, + testId, +}: { + anchor: AnchoredOperationPopoverAnchor + ariaLabel: string + children: React.ReactNode + className?: string + dismissDisabled?: boolean + estimatedHeight?: number + estimatedWidth?: number + onDismiss: () => void + testId: string +}): React.JSX.Element { + const ownerId = React.useId() + + return ( + + targetBelongsToTransientLayer(target, ownerId)} + onDismiss={onDismiss} + placement={{ + type: 'point', + point: anchor, + alignX: 'start', + alignY: 'start', + padding: 10, + estimatedSize: { width: estimatedWidth, height: estimatedHeight }, + }} + role="dialog" + waitForMeasurement + onContextMenu={event => { + event.stopPropagation() + }} + onPointerDown={event => { + event.stopPropagation() + }} + onWheel={event => { + event.stopPropagation() + }} + > + {children} + + + ) +} diff --git a/src/app/renderer/components/CoveSelect.tsx b/src/app/renderer/components/CoveSelect.tsx index 197e18158..d0b48540b 100644 --- a/src/app/renderer/components/CoveSelect.tsx +++ b/src/app/renderer/components/CoveSelect.tsx @@ -1,6 +1,10 @@ import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { ChevronDown } from 'lucide-react' +import { + TRANSIENT_LAYER_OWNER_ATTRIBUTE, + useTransientLayerOwner, +} from './TransientLayerOwnerContext' export interface CoveSelectOption { value: string @@ -48,6 +52,7 @@ export function CoveSelect({ onChange: (nextValue: string) => void }): React.JSX.Element { const listboxId = useId() + const transientLayerOwner = useTransientLayerOwner() const rootRef = useRef(null) const triggerRef = useRef(null) const menuRef = useRef(null) @@ -342,6 +347,9 @@ export function CoveSelect({ ref={menuRef} className={`cove-select__menu${menuClassName ? ` ${menuClassName}` : ''}`} data-testid={menuTestId ?? (testId ? `${testId}-menu` : undefined)} + {...(transientLayerOwner + ? { [TRANSIENT_LAYER_OWNER_ATTRIBUTE]: transientLayerOwner } + : {})} role="listbox" style={{ top: menuPosition.top, diff --git a/src/app/renderer/components/TransientLayerOwnerContext.tsx b/src/app/renderer/components/TransientLayerOwnerContext.tsx new file mode 100644 index 000000000..60e34f673 --- /dev/null +++ b/src/app/renderer/components/TransientLayerOwnerContext.tsx @@ -0,0 +1,27 @@ +import React from 'react' + +export const TRANSIENT_LAYER_OWNER_ATTRIBUTE = 'data-cove-transient-layer-owner' + +const TransientLayerOwnerContext = React.createContext(null) + +export const TransientLayerOwnerProvider = TransientLayerOwnerContext.Provider + +export function useTransientLayerOwner(): string | null { + return React.useContext(TransientLayerOwnerContext) +} + +export function targetBelongsToTransientLayer( + target: EventTarget | null, + ownerId: string, +): boolean { + let element = target instanceof Element ? target : null + + while (element) { + if (element.getAttribute(TRANSIENT_LAYER_OWNER_ATTRIBUTE) === ownerId) { + return true + } + element = element.parentElement + } + + return false +} diff --git a/src/app/renderer/components/ViewportMenuSurface.tsx b/src/app/renderer/components/ViewportMenuSurface.tsx index eec23e672..374b549f2 100644 --- a/src/app/renderer/components/ViewportMenuSurface.tsx +++ b/src/app/renderer/components/ViewportMenuSurface.tsx @@ -35,7 +35,9 @@ export interface ViewportMenuSurfaceProps extends Omit< dismissOnPointerDownOutside?: boolean dismissOnEscape?: boolean dismissIgnoreRefs?: Array> + dismissIgnoreTarget?: (target: EventTarget | null) => boolean stopEventPropagation?: boolean + waitForMeasurement?: boolean } function assignRef(ref: React.ForwardedRef, value: T): void { @@ -66,7 +68,9 @@ export const ViewportMenuSurface = React.forwardRef ref.current?.contains(target) ?? false) + return ( + dismissIgnoreRefs.some(ref => ref.current?.contains(target) ?? false) || + dismissIgnoreTarget?.(target) === true + ) } const handlePointerDown = (event: PointerEvent): void => { @@ -166,6 +173,10 @@ export const ViewportMenuSurface = React.forwardRef { if (placement.type === 'absolute') { @@ -203,14 +221,19 @@ export const ViewportMenuSurface = React.forwardRef { if (stopEventPropagation) { diff --git a/src/app/renderer/i18n/locales/en.worktree.ts b/src/app/renderer/i18n/locales/en.worktree.ts index b84b051bd..b9f22048c 100644 --- a/src/app/renderer/i18n/locales/en.worktree.ts +++ b/src/app/renderer/i18n/locales/en.worktree.ts @@ -11,6 +11,8 @@ export const enWorktree = { branchPlaceholder: 'e.g. space/infra-core', branchHasWorktree: 'worktree exists', createAndBind: 'Create & Bind', + creatingWorktree: 'Creating worktree…', + archivingSpace: 'Archiving Space…', deleteWorktree: 'Delete worktree', deleteWorktreeHelp: 'Remove the worktree directory from disk.', deleteBranch: 'Delete branch', diff --git a/src/app/renderer/i18n/locales/zh-CN.worktree.ts b/src/app/renderer/i18n/locales/zh-CN.worktree.ts index c4fb9d538..06253a852 100644 --- a/src/app/renderer/i18n/locales/zh-CN.worktree.ts +++ b/src/app/renderer/i18n/locales/zh-CN.worktree.ts @@ -11,6 +11,8 @@ export const zhCNWorktree = { branchPlaceholder: '例如:space/infra-core', branchHasWorktree: '已有 worktree', createAndBind: '创建并绑定', + creatingWorktree: '正在创建 worktree…', + archivingSpace: '正在归档 Space…', deleteWorktree: '删除 worktree', deleteWorktreeHelp: '从磁盘移除该 worktree 目录。', deleteBranch: '删除分支', diff --git a/src/app/renderer/shell/AppShell.tsx b/src/app/renderer/shell/AppShell.tsx index 96092f802..01c38c1e1 100644 --- a/src/app/renderer/shell/AppShell.tsx +++ b/src/app/renderer/shell/AppShell.tsx @@ -34,6 +34,7 @@ import { useWorkspaceStateHandlers } from './hooks/useWorkspaceStateHandlers' import { useAppUpdates } from './hooks/useAppUpdates' import { useAppShellWorkspaceActions } from './hooks/useAppShellWorkspaceActions' import { useShellOverlayState } from './hooks/useShellOverlayState' +import { useAddProjectRequest } from './hooks/useAddProjectWizardRequest' import { useWhatsNew } from './hooks/useWhatsNew' import { useWorkerSyncStateUpdates } from './hooks/useWorkerSyncStateUpdates' import { useWorkspaceMountRepair } from './hooks/useWorkspaceMountRepair' @@ -140,7 +141,7 @@ export default function App(): React.JSX.Element { const isPrimarySidebarCollapsed = agentSettings.isPrimarySidebarCollapsed === true - const [isFocusNodeTargetZoomPreviewing, setIsFocusNodeTargetZoomPreviewing] = useState(false) + const [isFocusNodeTargetZoomPreviewing, setFocusNodeZoomPreviewing] = useState(false) const [settingsInitialPageId, setSettingsInitialPageId] = useState(null) const { isCommandCenterOpen, @@ -150,6 +151,7 @@ export default function App(): React.JSX.Element { isWorkspaceSearchOpen, isSpaceArchivesOpen, isAddProjectWizardOpen, + addProjectWizardAnchor, hasBlockingOverlay, toggleCommandCenter, closeCommandCenter, @@ -229,7 +231,7 @@ export default function App(): React.JSX.Element { useEffect(() => { if (!isSettingsOpen) { - setIsFocusNodeTargetZoomPreviewing(false) + setFocusNodeZoomPreviewing(false) } }, [isSettingsOpen]) @@ -262,10 +264,7 @@ export default function App(): React.JSX.Element { onChangeSettings: setAgentSettings, }) - const handleAddWorkspace = useCallback((): void => { - setIsFocusNodeTargetZoomPreviewing(false) - openAddProjectWizard() - }, [openAddProjectWizard]) + const handleAddWorkspace = useAddProjectRequest(openAddProjectWizard, setFocusNodeZoomPreviewing) const { handleWorkspaceNodesChange, @@ -299,7 +298,7 @@ export default function App(): React.JSX.Element { const handleOpenSettings = useCallback( (initialPageId: SettingsPageId | null = null): void => { - setIsFocusNodeTargetZoomPreviewing(false) + setFocusNodeZoomPreviewing(false) setSettingsInitialPageId(initialPageId) closeTransientOverlays() setSettingsOpenPageId(null) @@ -445,6 +444,7 @@ export default function App(): React.JSX.Element { onDeleteSpaceArchiveRecord={handleWorkspaceSpaceArchiveRecordRemove} onCloseSpaceArchives={closeSpaceArchives} isAddProjectWizardOpen={isAddProjectWizardOpen} + addProjectWizardAnchor={addProjectWizardAnchor} onCloseAddProjectWizard={closeAddProjectWizard} projectContextMenu={projectContextMenu} projectMountManager={projectMountManager} @@ -478,14 +478,14 @@ export default function App(): React.JSX.Element { onWorkspaceWorktreesRootChange={handleAnyWorkspaceWorktreesRootChange} onWorkspaceEnvironmentVariablesChange={handleAnyWorkspaceEnvironmentVariablesChange} isFocusNodeTargetZoomPreviewing={isFocusNodeTargetZoomPreviewing} - onFocusNodeTargetZoomPreviewChange={setIsFocusNodeTargetZoomPreviewing} + onFocusNodeTargetZoomPreviewChange={setFocusNodeZoomPreviewing} onChangeSettings={setAgentSettings} onCheckForUpdates={checkForUpdates} onDownloadUpdate={downloadUpdate} onInstallUpdate={installUpdate} onCloseSettings={() => { flushPersistNow() - setIsFocusNodeTargetZoomPreviewing(false) + setFocusNodeZoomPreviewing(false) setSettingsInitialPageId(null) setSettingsOpenPageId(null) setIsSettingsOpen(false) diff --git a/src/app/renderer/shell/components/AddProjectWizardWindow.tsx b/src/app/renderer/shell/components/AddProjectWizardWindow.tsx index 193d263b7..947e09717 100644 --- a/src/app/renderer/shell/components/AddProjectWizardWindow.tsx +++ b/src/app/renderer/shell/components/AddProjectWizardWindow.tsx @@ -1,59 +1,58 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { FolderPlus } from 'lucide-react' +import { + AnchoredOperationPopover, + type AnchoredOperationPopoverAnchor, +} from '@app/renderer/components/AnchoredOperationPopover' import { useTranslation } from '@app/renderer/i18n' import type { WorkspaceState } from '@contexts/workspace/presentation/renderer/types' -import { basename, isAbsolutePath } from '../utils/pathHelpers' +import { basename } from '../utils/pathHelpers' import { RemoteDirectoryPickerWindow } from './RemoteDirectoryPickerWindow' import { RemoteEndpointStatusSlot } from './RemoteEndpointStatusSlot' -import { AddProjectWizardAdvancedSection } from './addProjectWizard/AddProjectWizardAdvancedSection' import { AddProjectWizardDefaultLocationSection, type DefaultLocationKind, } from './addProjectWizard/AddProjectWizardDefaultLocationSection' -import type { PlannedMount } from './addProjectWizard/AddProjectWizardPlannedMountsSection' -import type { DraftMount, RemotePickerState } from './addProjectWizard/helpers' +import type { RemotePickerState } from './addProjectWizard/helpers' import { useAddProjectWizardCreateProject } from './addProjectWizard/useAddProjectWizardCreateProject' import { useAddProjectWizardRemoteEndpoints } from './addProjectWizard/useAddProjectWizardRemoteEndpoints' + export function AddProjectWizardWindow({ + anchor = { x: 24, y: 64 }, existingWorkspaces, remoteWorkersEnabled, onClose, onRequestOpenEndpoints, }: { + anchor?: AnchoredOperationPopoverAnchor existingWorkspaces: WorkspaceState[] remoteWorkersEnabled: boolean onClose: () => void onRequestOpenEndpoints: () => void -}): React.JSX.Element { +}): React.JSX.Element | null { const { t } = useTranslation() - const [extraMounts, setExtraMounts] = useState([]) - const [projectName, setProjectName] = useState('') const [defaultLocationKind, setDefaultLocationKind] = useState('local') const [defaultLocalRootPath, setDefaultLocalRootPath] = useState('') const [defaultLocalMountName, setDefaultLocalMountName] = useState('') - const [defaultRemoteEndpointId, setDefaultRemoteEndpointId] = useState('') + const [defaultRemoteEndpointId, setDefaultRemoteEndpointId] = useState('') const [defaultRemoteRootPath, setDefaultRemoteRootPath] = useState('') const [defaultRemoteMountName, setDefaultRemoteMountName] = useState('') - const [isAdvancedOpen, setIsAdvancedOpen] = useState(false) - const [extraLocalRootPath, setExtraLocalRootPath] = useState('') - const [extraLocalMountName, setExtraLocalMountName] = useState('') - const [extraRemoteEndpointId, setExtraRemoteEndpointId] = useState('') - const [extraRemoteRootPath, setExtraRemoteRootPath] = useState('') - const [extraRemoteMountName, setExtraRemoteMountName] = useState('') + const [unusedExtraRemoteEndpointId, setUnusedExtraRemoteEndpointId] = useState('') const [remotePicker, setRemotePicker] = useState(null) const [error, setError] = useState(null) const [isBusy, setIsBusy] = useState(false) const [homeWorkerMode, setHomeWorkerMode] = useState<'standalone' | 'local' | 'remote' | null>( null, ) + const [isWorkerModeResolved, setIsWorkerModeResolved] = useState(false) + const didOpenNativePickerRef = useRef(false) + const { remoteOverviews, - endpointLabelById, endpointOptions, defaultRemoteOverview, - extraRemoteOverview, endpointError, busyByEndpointId, - reloadRemoteOverviews, runRemoteEndpointAction, reconnectRemoteEndpoint, } = useAddProjectWizardRemoteEndpoints({ @@ -61,8 +60,8 @@ export function AddProjectWizardWindow({ t, defaultRemoteEndpointId, setDefaultRemoteEndpointId, - extraRemoteEndpointId, - setExtraRemoteEndpointId, + extraRemoteEndpointId: unusedExtraRemoteEndpointId, + setExtraRemoteEndpointId: setUnusedExtraRemoteEndpointId, setError, }) @@ -72,271 +71,148 @@ export function AddProjectWizardWindow({ homeWorkerMode !== 'remote' useEffect(() => { - void (async () => { - try { - const config = await window.opencoveApi.workerClient.getConfig() - setHomeWorkerMode(config.mode) - } catch { - setHomeWorkerMode(null) - } - })() - }, []) + let cancelled = false + void window.opencoveApi.workerClient + .getConfig() + .then(config => { + if (!cancelled) { + setHomeWorkerMode(config.mode) + } + }) + .catch(() => { + if (!cancelled) { + setHomeWorkerMode(null) + } + }) + .finally(() => { + if (!cancelled) { + setIsWorkerModeResolved(true) + } + }) - useEffect(() => { - if (!remoteWorkersEnabled) { - setDefaultLocationKind('local') + return () => { + cancelled = true } - }, [remoteWorkersEnabled]) + }, []) const derivedProjectName = useMemo(() => { - const trimmed = projectName.trim() - if (trimmed.length > 0) { - return trimmed - } + const rootPath = defaultLocationKind === 'local' ? defaultLocalRootPath : defaultRemoteRootPath + return basename(rootPath).trim() + }, [defaultLocalRootPath, defaultLocationKind, defaultRemoteRootPath]) - const candidateRoot = - defaultLocationKind === 'local' ? defaultLocalRootPath.trim() : defaultRemoteRootPath.trim() - if (candidateRoot.length > 0) { - return basename(candidateRoot).trim() - } - - const fallback = extraMounts[0]?.rootPath - return fallback ? basename(fallback).trim() : '' - }, [defaultLocalRootPath, defaultLocationKind, defaultRemoteRootPath, extraMounts, projectName]) - - const displayError = error ?? endpointError - - const defaultMountPreview = useMemo(() => { - if (!remoteWorkersEnabled) { - const rootPath = defaultLocalRootPath.trim() - if (rootPath.length === 0) { - return null - } - - return { - endpointId: 'local', - rootPath, - name: defaultLocalMountName.trim() || basename(rootPath).trim() || null, - } - } - - if (defaultLocationKind === 'local') { - const rootPath = defaultLocalRootPath.trim() - if (rootPath.length === 0) { - return null - } - - return { - endpointId: 'local', - rootPath, - name: defaultLocalMountName.trim() || basename(rootPath).trim() || null, - } - } - - const endpointId = defaultRemoteEndpointId.trim() - const rootPath = defaultRemoteRootPath.trim() - if (endpointId.length === 0 || rootPath.length === 0) { - return null - } - - return { - endpointId, - rootPath, - name: defaultRemoteMountName.trim() || basename(rootPath).trim() || null, - } - }, [ - defaultLocalMountName, - defaultLocalRootPath, + const createProject = useAddProjectWizardCreateProject({ + t, + existingWorkspaces, + onClose, + isBusy, + setIsBusy, + setError, + derivedProjectName, defaultLocationKind, + defaultLocalRootPath, + defaultLocalMountName, defaultRemoteEndpointId, - defaultRemoteMountName, defaultRemoteRootPath, - remoteWorkersEnabled, - ]) - - const addExtraMountDraft = useCallback((draft: Omit) => { - setExtraMounts(prev => [ - ...prev, - { - id: crypto.randomUUID(), - ...draft, - }, - ]) - }, []) - - const removeExtraMountDraft = useCallback((draftId: string) => { - setExtraMounts(prev => prev.filter(item => item.id !== draftId)) - }, []) - - const browseDefaultLocalMount = useCallback(async () => { - if (!canBrowseLocal) { - return - } - - const selected = await window.opencoveApi.workspace.selectDirectory() - if (!selected) { - return - } - - setDefaultLocalRootPath(selected.path) - if (defaultLocalMountName.trim().length === 0) { - setDefaultLocalMountName(selected.name) - } - }, [canBrowseLocal, defaultLocalMountName]) - - const browseExtraLocalMount = useCallback(async () => { - if (!canBrowseLocal) { - return - } - - const selected = await window.opencoveApi.workspace.selectDirectory() - if (!selected) { - return - } - - setExtraLocalRootPath(selected.path) - if (extraLocalMountName.trim().length === 0) { - setExtraLocalMountName(selected.name) - } - }, [canBrowseLocal, extraLocalMountName]) + defaultRemoteMountName, + extraMounts: [], + }) - const openRemotePicker = useCallback( - (target: 'default' | 'extra') => { - const endpointId = - target === 'default' ? defaultRemoteEndpointId.trim() : extraRemoteEndpointId.trim() - if (endpointId.length === 0) { + const chooseLocalFolder = useCallback( + async (createImmediately: boolean) => { + if (!canBrowseLocal || isBusy) { return } - const endpointLabel = endpointLabelById.get(endpointId) ?? endpointId - const initialPath = - target === 'default' ? defaultRemoteRootPath.trim() : extraRemoteRootPath.trim() + const selected = await window.opencoveApi.workspace.selectDirectory() + if (!selected) { + if (!remoteWorkersEnabled) { + onClose() + } + return + } - setRemotePicker({ - target, - endpointId, - endpointLabel, - initialPath: initialPath.length > 0 ? initialPath : null, - }) + setDefaultLocationKind('local') + setDefaultLocalRootPath(selected.path) + setDefaultLocalMountName(selected.name) + if (createImmediately) { + await createProject({ + derivedProjectName: selected.name || basename(selected.path), + defaultLocationKind: 'local', + defaultLocalRootPath: selected.path, + defaultLocalMountName: selected.name, + extraMounts: [], + }) + } }, - [ - defaultRemoteEndpointId, - defaultRemoteRootPath, - endpointLabelById, - extraRemoteEndpointId, - extraRemoteRootPath, - ], + [canBrowseLocal, createProject, isBusy, onClose, remoteWorkersEnabled], ) - const addExtraLocalMount = useCallback(() => { - const rootPath = extraLocalRootPath.trim() - if (rootPath.length === 0) { - return - } - - if (!isAbsolutePath(rootPath)) { - setError(t('addProjectWizard.localPathMustBeAbsolute')) + useEffect(() => { + if ( + remoteWorkersEnabled || + !isWorkerModeResolved || + !canBrowseLocal || + didOpenNativePickerRef.current + ) { return } - addExtraMountDraft({ - endpointId: 'local', - rootPath, - name: extraLocalMountName.trim() || basename(rootPath).trim() || null, - }) - setExtraLocalRootPath('') - setExtraLocalMountName('') - }, [addExtraMountDraft, extraLocalMountName, extraLocalRootPath, t]) - - const addExtraRemoteMount = useCallback(() => { - const endpointId = extraRemoteEndpointId.trim() - const rootPath = extraRemoteRootPath.trim() - if (endpointId.length === 0 || rootPath.length === 0) { - return - } + didOpenNativePickerRef.current = true + void chooseLocalFolder(true) + }, [canBrowseLocal, chooseLocalFolder, isWorkerModeResolved, remoteWorkersEnabled]) - if (!isAbsolutePath(rootPath)) { - setError(t('addProjectWizard.remotePathMustBeAbsolute')) + const openRemotePicker = useCallback(() => { + const endpointId = defaultRemoteEndpointId.trim() + if (endpointId.length === 0) { return } - addExtraMountDraft({ + const endpointLabel = + endpointOptions.find(option => option.value === endpointId)?.label ?? endpointId + setRemotePicker({ + target: 'default', endpointId, - rootPath, - name: extraRemoteMountName.trim() || basename(rootPath).trim() || null, + endpointLabel, + initialPath: defaultRemoteRootPath.trim() || null, }) - setExtraRemoteRootPath('') - setExtraRemoteMountName('') - }, [addExtraMountDraft, extraRemoteEndpointId, extraRemoteMountName, extraRemoteRootPath, t]) + }, [defaultRemoteEndpointId, defaultRemoteRootPath, endpointOptions]) - const createProject = useAddProjectWizardCreateProject({ - t, - existingWorkspaces, - onClose, - isBusy, - setIsBusy, - setError, - derivedProjectName, - defaultLocationKind, - defaultLocalRootPath, - defaultLocalMountName, - defaultRemoteEndpointId, - defaultRemoteRootPath, - defaultRemoteMountName, - extraMounts, - }) + if (!remoteWorkersEnabled && canBrowseLocal && !error) { + return null + } - const canCreateExtraRemote = - extraRemoteEndpointId.trim().length > 0 && extraRemoteRootPath.trim().length > 0 + const displayError = error ?? endpointError return ( <> -
{ - if (isBusy) { - return - } - - onClose() - }} - > -
event.stopPropagation()} + {remotePicker === null ? ( + -

{t('addProjectWizard.title')}

-

- {remoteWorkersEnabled - ? t('addProjectWizard.description') - : t('addProjectWizard.descriptionLocalOnly')} -

+
+
+ +
+

{t('addProjectWizard.title')}

+

{t('addProjectWizard.description')}

+
+
-
{displayError ? (

{displayError}

) : null} -
- - setProjectName(event.target.value)} - disabled={isBusy} - placeholder={t('addProjectWizard.namePlaceholder')} - data-testid="workspace-project-create-name" - /> -
- } onChangeDefaultLocationKind={setDefaultLocationKind} - onChangeDefaultLocalRootPath={setDefaultLocalRootPath} - onBrowseDefaultLocalRootPath={() => void browseDefaultLocalMount()} + onChangeDefaultLocalRootPath={value => { + setDefaultLocalRootPath(value) + setDefaultLocalMountName(basename(value)) + }} + onBrowseDefaultLocalRootPath={() => { + void chooseLocalFolder(true) + }} onChangeDefaultRemoteEndpointId={setDefaultRemoteEndpointId} - onChangeDefaultRemoteRootPath={setDefaultRemoteRootPath} - onBrowseDefaultRemoteRootPath={() => { - if (!remoteWorkersEnabled) { - return - } - - openRemotePicker('default') + onChangeDefaultRemoteRootPath={value => { + setDefaultRemoteRootPath(value) + setDefaultRemoteMountName(basename(value)) }} + onBrowseDefaultRemoteRootPath={openRemotePicker} onRequestOpenEndpoints={onRequestOpenEndpoints} /> - {remoteWorkersEnabled ? ( - { - void runRemoteEndpointAction(endpointId) - }} - onReconnect={endpointId => { - void reconnectRemoteEndpoint(endpointId) - }} - /> - } - onToggleAdvanced={() => setIsAdvancedOpen(open => !open)} - onChangeExtraLocalRootPath={setExtraLocalRootPath} - onChangeExtraLocalMountName={setExtraLocalMountName} - onBrowseExtraLocalRootPath={() => void browseExtraLocalMount()} - onAddExtraLocalMount={addExtraLocalMount} - onChangeExtraRemoteEndpointId={setExtraRemoteEndpointId} - onChangeExtraRemoteRootPath={setExtraRemoteRootPath} - onChangeExtraRemoteMountName={setExtraRemoteMountName} - onBrowseExtraRemoteRootPath={() => { - openRemotePicker('extra') - }} - onAddExtraRemoteMount={() => { - addExtraRemoteMount() - }} - onRemoveExtraMount={removeExtraMountDraft} - onReloadEndpoints={() => { - void reloadRemoteOverviews() +
+ +
- -
- - -
-
-
+ data-testid="workspace-project-create-confirm" + > + {isBusy ? t('common.loading') : t('common.create')} + + + + + ) : null} { - const target = remotePicker?.target ?? null setRemotePicker(null) - - if (!target) { - return - } - - if (target === 'default') { - setDefaultRemoteRootPath(path) - if (defaultRemoteMountName.trim().length === 0) { - setDefaultRemoteMountName(basename(path)) - } - return - } - - setExtraRemoteRootPath(path) - if (extraRemoteMountName.trim().length === 0) { - setExtraRemoteMountName(basename(path)) - } + setDefaultRemoteRootPath(path) + setDefaultRemoteMountName(basename(path)) }} /> diff --git a/src/app/renderer/shell/components/AppShellPopups.tsx b/src/app/renderer/shell/components/AppShellPopups.tsx index b0c21130d..d811ac5b8 100644 --- a/src/app/renderer/shell/components/AppShellPopups.tsx +++ b/src/app/renderer/shell/components/AppShellPopups.tsx @@ -38,6 +38,7 @@ export function AppShellPopups({ onDeleteSpaceArchiveRecord, onCloseSpaceArchives, isAddProjectWizardOpen, + addProjectWizardAnchor, onCloseAddProjectWizard, projectContextMenu, projectMountManager, @@ -60,7 +61,7 @@ export function AppShellPopups({ onRequestOpenEndpoints: () => void onOpenSpaceArchives: () => void onTogglePrimarySidebar: () => void - onAddWorkspace: () => void + onAddWorkspace: (anchor?: { x: number; y: number }) => void onSelectWorkspace: (workspaceId: string) => void onSelectSpace: (spaceId: string | null) => void isSpaceArchivesOpen: boolean @@ -70,6 +71,7 @@ export function AppShellPopups({ onDeleteSpaceArchiveRecord: (recordId: string) => void onCloseSpaceArchives: () => void isAddProjectWizardOpen: boolean + addProjectWizardAnchor: { x: number; y: number } onCloseAddProjectWizard: () => void projectContextMenu: ProjectContextMenuState | null projectMountManager: ProjectMountManagerState | null @@ -126,6 +128,7 @@ export function AppShellPopups({ {isAddProjectWizardOpen ? ( { diff --git a/src/app/renderer/shell/components/Sidebar.tsx b/src/app/renderer/shell/components/Sidebar.tsx index 1da8b8e54..e8730736b 100644 --- a/src/app/renderer/shell/components/Sidebar.tsx +++ b/src/app/renderer/shell/components/Sidebar.tsx @@ -43,7 +43,7 @@ type SidebarProps = { activeWorkspaceId: string | null persistNotice: PersistNotice | null onTogglePinned?: () => void - onAddProject?: () => void + onAddProject?: (anchor?: { x: number; y: number }) => void onSelectWorkspace: (workspaceId: string) => void onSelectSpace: (workspaceId: string, spaceId: string) => void onOpenProjectContextMenu: (state: ProjectContextMenuState) => void diff --git a/src/app/renderer/shell/components/SidebarToolbar.tsx b/src/app/renderer/shell/components/SidebarToolbar.tsx index 2782d78e4..704207da3 100644 --- a/src/app/renderer/shell/components/SidebarToolbar.tsx +++ b/src/app/renderer/shell/components/SidebarToolbar.tsx @@ -11,7 +11,7 @@ export function SidebarToolbar({ isPinned: boolean showAddProject: boolean onTogglePinned: () => void - onAddProject: () => void + onAddProject: (anchor?: { x: number; y: number }) => void }): React.JSX.Element { const { t } = useTranslation() const pinLabel = isPinned ? t('sidebar.unpin') : t('sidebar.pin') @@ -36,7 +36,13 @@ export function SidebarToolbar({ data-testid="workspace-sidebar-add-project" aria-label={t('sidebar.addProject')} title={t('sidebar.addProject')} - onClick={onAddProject} + onClick={event => { + const rect = event.currentTarget.getBoundingClientRect() + onAddProject({ + x: Math.round(rect.left), + y: Math.round(rect.bottom + 6), + }) + }} >