diff --git a/mobile/app/h/[hostId]/files/[worktreeId].tsx b/mobile/app/h/[hostId]/files/[worktreeId].tsx index 8180baafb81..0bd81be07ec 100644 --- a/mobile/app/h/[hostId]/files/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/[worktreeId].tsx @@ -187,7 +187,7 @@ export default function MobileFileExplorerScreen() { setOpeningPath(null) } }, - [client, hostId, name, router, worktreeId] + [client, router, worktreeId] ) const renderItem: ListRenderItem = ({ item }) => { @@ -348,9 +348,7 @@ const styles = StyleSheet.create({ color: colors.textSecondary, fontSize: typography.metaSize }, - list: { - flex: 1 - }, + list: { flex: 1 }, listContent: { paddingVertical: spacing.sm }, diff --git a/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx b/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx index 0ac1b3241b4..bddeeb43843 100644 --- a/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx +++ b/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx @@ -20,7 +20,7 @@ export function ScrollToCurrentWorkspaceToolbarButton(): React.JSX.Element { onClick={requestScrollToCurrentWorkspaceReveal} className="text-muted-foreground" > - + diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 7094964e2e7..a091d9c10bf 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -1,95 +1,26 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { Kanban, Plus } from 'lucide-react' +import React from 'react' +import { FolderPlus, Plus } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import SidebarWorkspaceOptionsMenu from './SidebarWorkspaceOptionsMenu' import SidebarHostScopeStrip from './SidebarHostScopeStrip' -import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { openWorkspaceCreationComposerWithTourHandoff } from '../contextual-tours/workspace-creation-tour-handoff' import { translate } from '@/i18n/i18n' -const SidebarHeader = React.memo(function SidebarHeader() { +type SidebarHeaderProps = { + onWorkspaceBoardMenuOpenChange: (open: boolean) => void +} + +const SidebarHeader = React.memo(function SidebarHeader({ + onWorkspaceBoardMenuOpenChange +}: SidebarHeaderProps) { + const openModal = useAppStore((s) => s.openModal) const newWorktreeShortcutLabel = useShortcutLabel('workspace.create') - const [workspaceBoardOpen, setWorkspaceBoardOpen] = useState(false) - const [workspaceBoardMenuOpen, setWorkspaceBoardMenuOpen] = useState(false) - const workspaceBoardOpenRef = useRef(workspaceBoardOpen) const groupBy = useAppStore((s) => s.groupBy) const canCreateWorkspace = useAppStore((s) => s.repos.length > 0) const sidebarTitle = groupBy === 'repo' ? 'Projects' : 'Workspaces' - workspaceBoardOpenRef.current = workspaceBoardOpen - - const openWorkspaceBoard = useCallback(() => { - if (workspaceBoardOpenRef.current) { - return - } - workspaceBoardOpenRef.current = true - // Why: opening the board is the user action; recording here avoids a - // post-render bookkeeping Effect in the drawer. - useAppStore.getState().recordFeatureInteraction('workspace-board') - setWorkspaceBoardOpen(true) - }, []) - - const closeWorkspaceBoard = useCallback(() => { - workspaceBoardOpenRef.current = false - setWorkspaceBoardOpen(false) - setWorkspaceBoardMenuOpen(false) - }, []) - - const handleWorkspaceBoardOpenChange = useCallback( - (open: boolean) => { - if (open) { - openWorkspaceBoard() - return - } - closeWorkspaceBoard() - }, - [closeWorkspaceBoard, openWorkspaceBoard] - ) - - const handleWorkspaceBoardToggle = useCallback(() => { - if (workspaceBoardOpen) { - closeWorkspaceBoard() - return - } - openWorkspaceBoard() - }, [closeWorkspaceBoard, openWorkspaceBoard, workspaceBoardOpen]) - - useEffect(() => { - if (!workspaceBoardOpen) { - return - } - - const handleKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Escape') { - return - } - if (workspaceBoardMenuOpen) { - return - } - // Why: Escape must dismiss any nested overlay (Radix dropdown, popover, - // tooltip, dialog, context menu) ahead of collapsing this non-modal - // companion panel. Radix portals open popper content into a wrapper - // element, and dialogs/menus expose `data-state="open"` on their - // content node, so the presence of either signals the user's intent - // is to dismiss that overlay rather than the workspace board. - if ( - document.querySelector( - '[data-radix-popper-content-wrapper], [role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [role="menu"][data-state="open"], [role="listbox"][data-state="open"]' - ) - ) { - return - } - event.preventDefault() - closeWorkspaceBoard() - } - - // Why: the workspace board is a non-modal companion panel, so focus may - // be outside the sheet when Escape should still dismiss it. - document.addEventListener('keydown', handleKeyDown, true) - return () => document.removeEventListener('keydown', handleKeyDown, true) - }, [closeWorkspaceBoard, workspaceBoardMenuOpen, workspaceBoardOpen]) return ( <> @@ -102,33 +33,26 @@ const SidebarHeader = React.memo(function SidebarHeader() {
- {workspaceBoardOpen - ? translate( - 'auto.components.sidebar.SidebarHeader.a30e34eb5c', - 'Close workspace board' - ) - : translate('auto.components.sidebar.SidebarHeader.49f62c5665', 'Workspace board')} + {translate('auto.components.sidebar.SidebarHeader.25a95899c9', 'Add Project')} @@ -138,6 +62,9 @@ const SidebarHeader = React.memo(function SidebarHeader() { variant="ghost" size="icon-xs" onClick={() => { + if (!canCreateWorkspace) { + return + } // Why: the parallel-work tour must click the real sidebar // control so it can hand off to the workspace-creation tour. openWorkspaceCreationComposerWithTourHandoff() @@ -168,12 +95,6 @@ const SidebarHeader = React.memo(function SidebarHeader() {
- ) }) diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx index a56c8ba7092..750551e483a 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx @@ -173,7 +173,7 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { onPointerDown={(event) => revealAdminOptions(event.altKey)} onClick={(event) => revealAdminOptions(event.altKey)} > - + diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx new file mode 100644 index 00000000000..a9d141e9237 --- /dev/null +++ b/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import SidebarToolbar from './SidebarToolbar' + +const mocks = vi.hoisted(() => ({ + activeTooltipOpen: false, + state: { + persistedUIReady: true, + featureInteractions: {} + } as Partial +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Partial) => unknown) => selector(mocks.state) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children, open }: { children: ReactNode; open?: boolean }) => { + mocks.activeTooltipOpen = open === true + return <>{children} + }, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => + mocks.activeTooltipOpen ? {children} : null +})) + +vi.mock('./ScrollToCurrentWorkspaceToolbarButton', () => ({ + ScrollToCurrentWorkspaceToolbarButton: () => +})) + +vi.mock('./SidebarSettingsHelpMenu', () => ({ + SidebarSettingsHelpMenu: () => +})) + +const roots: Root[] = [] + +async function renderToolbar(onWorkspaceBoardToggle = vi.fn()): Promise<{ + container: HTMLDivElement + rerender: () => Promise + onWorkspaceBoardToggle: ReturnType +}> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + + const render = async (): Promise => { + await act(async () => { + root.render( + + ) + }) + } + await render() + + return { container, rerender: render, onWorkspaceBoardToggle } +} + +describe('SidebarToolbar moved workspace board hint', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + window.localStorage.clear() + mocks.activeTooltipOpen = false + mocks.state = { + persistedUIReady: true, + featureInteractions: {} + } + }) + + afterEach(() => { + roots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + vi.clearAllMocks() + }) + + it('does not show the moved hint to brand-new users after their first board click', async () => { + const onWorkspaceBoardToggle = vi.fn(() => { + mocks.state = { + ...mocks.state, + featureInteractions: { + 'workspace-board': { firstInteractedAt: Date.now(), interactionCount: 1 } + } + } + }) + const { container, rerender } = await renderToolbar(onWorkspaceBoardToggle) + + expect(container.textContent).not.toContain('Workspace board moved to the bottom bar') + + const boardButton = container.querySelector( + 'button[aria-label="Workspace board"]' + ) + expect(boardButton).not.toBeNull() + await act(async () => { + boardButton?.click() + }) + await rerender() + + expect(onWorkspaceBoardToggle).toHaveBeenCalledOnce() + expect(container.textContent).not.toContain('Workspace board moved to the bottom bar') + expect(window.localStorage.getItem('orca.workspaceBoardMovedHintSeen.v1')).toBeNull() + }) + + it('shows the moved hint once to users who had already used the workspace board', async () => { + mocks.state = { + persistedUIReady: true, + featureInteractions: { + 'workspace-board': { firstInteractedAt: 100, interactionCount: 2 } + } + } + + const { container } = await renderToolbar() + + expect(container.textContent).toContain('Workspace board moved to the bottom bar') + expect(window.localStorage.getItem('orca.workspaceBoardMovedHintSeen.v1')).toBe('true') + }) +}) diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.tsx index 3d8e274c3c7..5d090de10a2 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.tsx @@ -1,42 +1,106 @@ import React from 'react' -import { FolderPlus } from 'lucide-react' -import { useAppStore } from '@/store' +import { Kanban } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { ScrollToCurrentWorkspaceToolbarButton } from './ScrollToCurrentWorkspaceToolbarButton' import { SidebarSettingsHelpMenu } from './SidebarSettingsHelpMenu' import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { hasFeatureInteraction } from '../../../../shared/feature-interactions' -const SidebarToolbar = React.memo(function SidebarToolbar() { - const openModal = useAppStore((s) => s.openModal) +const WORKSPACE_BOARD_MOVED_HINT_STORAGE_KEY = 'orca.workspaceBoardMovedHintSeen.v1' +const WORKSPACE_BOARD_MOVED_HINT_DURATION_MS = 12000 + +type SidebarToolbarProps = { + workspaceBoardOpen: boolean + onWorkspaceBoardToggle: () => void +} + +const SidebarToolbar = React.memo(function SidebarToolbar({ + workspaceBoardOpen, + onWorkspaceBoardToggle +}: SidebarToolbarProps) { + const [workspaceBoardMovedHintOpen, setWorkspaceBoardMovedHintOpen] = React.useState(false) + const movedHintEligibleRef = React.useRef(null) + const persistedUIReady = useAppStore((state) => state.persistedUIReady) + const hasUsedWorkspaceBoard = useAppStore((state) => + hasFeatureInteraction(state.featureInteractions, 'workspace-board') + ) + + React.useEffect(() => { + if (!persistedUIReady) { + return + } + // Why: only users who had already opened the old board location should + // see the relocation hint; first-time users should not become eligible. + if (movedHintEligibleRef.current === null) { + movedHintEligibleRef.current = hasUsedWorkspaceBoard + } + if (!movedHintEligibleRef.current) { + return + } + try { + if (window.localStorage.getItem(WORKSPACE_BOARD_MOVED_HINT_STORAGE_KEY) === 'true') { + return + } + window.localStorage.setItem(WORKSPACE_BOARD_MOVED_HINT_STORAGE_KEY, 'true') + } catch { + return + } + + setWorkspaceBoardMovedHintOpen(true) + const timeoutId = window.setTimeout(() => { + setWorkspaceBoardMovedHintOpen(false) + }, WORKSPACE_BOARD_MOVED_HINT_DURATION_MS) + return () => window.clearTimeout(timeoutId) + }, [hasUsedWorkspaceBoard, persistedUIReady]) + + const handleWorkspaceBoardClick = (): void => { + setWorkspaceBoardMovedHintOpen(false) + onWorkspaceBoardToggle() + } return (
- - - - - - {translate( - 'auto.components.sidebar.SidebarToolbar.19e32d0e5f', - 'Open folder picker to add a project' - )} - - -
+ +
- + + + + + + {workspaceBoardMovedHintOpen + ? translate( + 'auto.components.sidebar.SidebarToolbar.87d0064026', + 'Workspace board moved to the bottom bar' + ) + : workspaceBoardOpen + ? translate( + 'auto.components.sidebar.SidebarToolbar.a30e34eb5c', + 'Close workspace board' + ) + : translate( + 'auto.components.sidebar.SidebarToolbar.49f62c5665', + 'Workspace board' + )} + +
diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index 629fa8371a8..ba0528fa729 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -490,6 +490,7 @@ export default function WorkspaceKanbanDrawer({ } as React.CSSProperties } data-contextual-tour-target="workspace-board-surface" + data-workspace-board-sheet="" onOpenAutoFocus={(event) => { // Why: Radix focuses the first toolbar button on open, which opens // its tooltip without hover and makes the drawer feel noisy. diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 76d241e5df7..d51dd2c2924 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -7,10 +7,12 @@ import SidebarNav from './SidebarNav' import SetupScriptPromptCard from './SetupScriptPromptCard' import WorktreeList from './WorktreeList' import SidebarToolbar from './SidebarToolbar' +import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' import { cn } from '@/lib/utils' import { FolderPlus, Loader2 } from 'lucide-react' import { useSidebarProjectDrop } from './useSidebarProjectDrop' +import { useWorkspaceBoardPanel } from './useWorkspaceBoardPanel' const WorktreeMetaDialog = React.lazy(() => import('./WorktreeMetaDialog')) const NonGitFolderDialog = React.lazy(() => import('./NonGitFolderDialog')) @@ -45,6 +47,14 @@ function Sidebar({ const { nativeDropTarget, dropHandlers, affordance } = useSidebarProjectDrop() const [shouldMountAddRepoDialog, setShouldMountAddRepoDialog] = React.useState(false) const unmountAddRepoDialogTimerRef = React.useRef | null>(null) + const { + workspaceBoardOpen, + workspaceBoardMenuOpen, + toggleWorkspaceBoard, + handleWorkspaceBoardOpenChange, + setWorkspaceBoardMenuOpen, + closeWorkspaceBoard + } = useWorkspaceBoardPanel() const setLiveSidebarWidth = React.useCallback((width: number) => { document.documentElement.style.setProperty('--workspace-sidebar-live-width', `${width}px`) @@ -83,6 +93,12 @@ function Sidebar({ } }, [activeModal, shouldMountAddRepoDialog]) + useEffect(() => { + if (!sidebarOpen && workspaceBoardOpen) { + closeWorkspaceBoard() + } + }, [closeWorkspaceBoard, sidebarOpen, workspaceBoardOpen]) + const { containerRef, onResizeStart } = useSidebarResize({ isOpen: sidebarOpen, width: sidebarWidth, @@ -105,7 +121,7 @@ function Sidebar({ <> {/* Fixed controls */} - + {/* Fixed bottom toolbar */} - + )} @@ -160,6 +179,14 @@ function Sidebar({ {activeModal === 'worktree-visibility' ? : null} {activeModal === 'confirm-orca-yaml-hooks' ? : null} + {sidebarOpen ? ( + + ) : null} ) } diff --git a/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx b/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx new file mode 100644 index 00000000000..f1ab637257a --- /dev/null +++ b/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useWorkspaceBoardPanel, type WorkspaceBoardPanelState } from './useWorkspaceBoardPanel' + +const mocks = vi.hoisted(() => ({ + recordFeatureInteraction: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + recordFeatureInteraction: mocks.recordFeatureInteraction + }) + } +})) + +let latestState: WorkspaceBoardPanelState | null = null +const roots: Root[] = [] + +function HookProbe(): null { + latestState = useWorkspaceBoardPanel() + return null +} + +async function renderHookProbe(): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render() + }) +} + +function panelState(): WorkspaceBoardPanelState { + if (!latestState) { + throw new Error('Hook state has not been rendered') + } + return latestState +} + +async function updatePanel(update: (state: WorkspaceBoardPanelState) => void): Promise { + await act(async () => { + update(panelState()) + }) +} + +async function pressEscape(): Promise { + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) +} + +describe('useWorkspaceBoardPanel', () => { + beforeEach(() => { + latestState = null + mocks.recordFeatureInteraction.mockReset() + }) + + afterEach(() => { + roots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + }) + + it('toggles the board and records the feature interaction when opened', async () => { + await renderHookProbe() + + await updatePanel((state) => state.toggleWorkspaceBoard()) + + expect(panelState().workspaceBoardOpen).toBe(true) + expect(mocks.recordFeatureInteraction).toHaveBeenCalledExactlyOnceWith('workspace-board') + + await updatePanel((state) => state.toggleWorkspaceBoard()) + + expect(panelState().workspaceBoardOpen).toBe(false) + expect(mocks.recordFeatureInteraction).toHaveBeenCalledOnce() + }) + + it('keeps the board open on Escape while a nested board menu is open', async () => { + await renderHookProbe() + + await updatePanel((state) => state.openWorkspaceBoard()) + await updatePanel((state) => state.setWorkspaceBoardMenuOpen(true)) + await pressEscape() + + expect(panelState().workspaceBoardOpen).toBe(true) + + await updatePanel((state) => state.setWorkspaceBoardMenuOpen(false)) + await pressEscape() + + expect(panelState().workspaceBoardOpen).toBe(false) + }) + + it('lets Escape close the board while non-interactive tooltip content is open', async () => { + await renderHookProbe() + const tooltip = document.createElement('div') + tooltip.setAttribute('data-slot', 'tooltip-content') + tooltip.setAttribute('data-state', 'open') + document.body.appendChild(tooltip) + + await updatePanel((state) => state.openWorkspaceBoard()) + await pressEscape() + + expect(panelState().workspaceBoardOpen).toBe(false) + }) + + it('lets Escape close the board when the board sheet itself is the open dialog', async () => { + await renderHookProbe() + const boardSheet = document.createElement('div') + boardSheet.setAttribute('role', 'dialog') + boardSheet.setAttribute('data-state', 'open') + boardSheet.setAttribute('data-workspace-board-sheet', '') + document.body.appendChild(boardSheet) + + await updatePanel((state) => state.openWorkspaceBoard()) + await pressEscape() + + expect(panelState().workspaceBoardOpen).toBe(false) + }) + + it('keeps the board open on Escape while an interactive popover is open', async () => { + await renderHookProbe() + const popover = document.createElement('div') + popover.setAttribute('data-slot', 'popover-content') + popover.setAttribute('data-state', 'open') + document.body.appendChild(popover) + + await updatePanel((state) => state.openWorkspaceBoard()) + await pressEscape() + + expect(panelState().workspaceBoardOpen).toBe(true) + }) + + it('keeps the board open on Escape while a nested dialog is open', async () => { + await renderHookProbe() + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + dialog.setAttribute('data-state', 'open') + document.body.appendChild(dialog) + + await updatePanel((state) => state.openWorkspaceBoard()) + await pressEscape() + + expect(panelState().workspaceBoardOpen).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.ts b/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.ts new file mode 100644 index 00000000000..02d9edfb7df --- /dev/null +++ b/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.ts @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAppStore } from '@/store' + +const WORKSPACE_BOARD_ESCAPE_BLOCKING_OVERLAY_SELECTOR = [ + '[data-slot="dropdown-menu-content"][data-state="open"]', + '[data-slot="context-menu-content"][data-state="open"]', + '[data-slot="popover-content"][data-state="open"]', + '[role="dialog"][data-state="open"]:not([data-workspace-board-sheet])', + '[role="alertdialog"][data-state="open"]', + '[role="menu"][data-state="open"]', + '[role="listbox"][data-state="open"]' +].join(', ') + +export type WorkspaceBoardPanelState = { + workspaceBoardOpen: boolean + workspaceBoardMenuOpen: boolean + openWorkspaceBoard: () => void + closeWorkspaceBoard: () => void + toggleWorkspaceBoard: () => void + handleWorkspaceBoardOpenChange: (open: boolean) => void + setWorkspaceBoardMenuOpen: (open: boolean) => void +} + +export function useWorkspaceBoardPanel(): WorkspaceBoardPanelState { + const [workspaceBoardOpen, setWorkspaceBoardOpen] = useState(false) + const [workspaceBoardMenuOpen, setWorkspaceBoardMenuOpen] = useState(false) + const workspaceBoardOpenRef = useRef(workspaceBoardOpen) + workspaceBoardOpenRef.current = workspaceBoardOpen + + const openWorkspaceBoard = useCallback(() => { + if (workspaceBoardOpenRef.current) { + return + } + workspaceBoardOpenRef.current = true + // Why: opening the board is the user action; recording here avoids a + // post-render bookkeeping Effect in the drawer. + useAppStore.getState().recordFeatureInteraction('workspace-board') + setWorkspaceBoardOpen(true) + }, []) + + const closeWorkspaceBoard = useCallback(() => { + workspaceBoardOpenRef.current = false + setWorkspaceBoardOpen(false) + setWorkspaceBoardMenuOpen(false) + }, []) + + const handleWorkspaceBoardOpenChange = useCallback( + (open: boolean) => { + if (open) { + openWorkspaceBoard() + return + } + closeWorkspaceBoard() + }, + [closeWorkspaceBoard, openWorkspaceBoard] + ) + + const toggleWorkspaceBoard = useCallback(() => { + if (workspaceBoardOpenRef.current) { + closeWorkspaceBoard() + return + } + openWorkspaceBoard() + }, [closeWorkspaceBoard, openWorkspaceBoard]) + + useEffect(() => { + if (!workspaceBoardOpen) { + return + } + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') { + return + } + if (workspaceBoardMenuOpen) { + return + } + // Why: Escape should dismiss interactive nested overlays before this + // companion panel, but non-interactive tooltips should not trap it. + if (document.querySelector(WORKSPACE_BOARD_ESCAPE_BLOCKING_OVERLAY_SELECTOR)) { + return + } + event.preventDefault() + closeWorkspaceBoard() + } + + // Why: the workspace board is a non-modal companion panel, so focus may + // be outside the sheet when Escape should still dismiss it. + document.addEventListener('keydown', handleKeyDown, true) + return () => document.removeEventListener('keydown', handleKeyDown, true) + }, [closeWorkspaceBoard, workspaceBoardMenuOpen, workspaceBoardOpen]) + + return { + workspaceBoardOpen, + workspaceBoardMenuOpen, + openWorkspaceBoard, + closeWorkspaceBoard, + toggleWorkspaceBoard, + handleWorkspaceBoardOpenChange, + setWorkspaceBoardMenuOpen + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c5e0a2dd9de..09a51c861b0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3347,6 +3347,7 @@ "ee240a39eb": "Edit filters" }, "SidebarHeader": { + "25a95899c9": "Add Project", "92154beb7e": "New workspace", "49f62c5665": "Workspace board", "5c9c7c16aa": "Add a project to create workspaces", @@ -3396,6 +3397,9 @@ "c4f8e1b72a": "X" }, "SidebarToolbar": { + "87d0064026": "Workspace board moved to the bottom bar", + "a30e34eb5c": "Close workspace board", + "49f62c5665": "Workspace board", "19e32d0e5f": "Open folder picker to add a project", "abc62b6328": "Add Project" }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0447a08236c..928510afe92 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3330,7 +3330,8 @@ "49f62c5665": "tablero del espacio de trabajo", "5c9c7c16aa": "Agregar un proyecto para crear espacios de trabajo", "ca6f729da2": "Nuevo espacio de trabajo ({{value0}})", - "a30e34eb5c": "Cerrar tablero del espacio de trabajo" + "a30e34eb5c": "Cerrar tablero del espacio de trabajo", + "25a95899c9": "Agregar proyecto" }, "SidebarNav": { "80611a8b10": "Buscar", @@ -3376,7 +3377,10 @@ }, "SidebarToolbar": { "19e32d0e5f": "Abra el selector de carpetas para agregar un proyecto", - "abc62b6328": "Agregar proyecto" + "abc62b6328": "Agregar proyecto", + "87d0064026": "El tablero del espacio de trabajo se movió a la barra inferior", + "a30e34eb5c": "Cerrar tablero del espacio de trabajo", + "49f62c5665": "tablero del espacio de trabajo" }, "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "Ocultar rama predeterminada", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 12a643207db..2e60dbf9573 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3332,7 +3332,8 @@ "49f62c5665": "ワークスペースボード", "5c9c7c16aa": "プロジェクトを追加してワークスペースを作成する", "ca6f729da2": "新規ワークスペース ({{value0}})", - "a30e34eb5c": "ワークスペースボードを閉じる" + "a30e34eb5c": "ワークスペースボードを閉じる", + "25a95899c9": "プロジェクトを追加" }, "SidebarNav": { "80611a8b10": "検索", @@ -3378,7 +3379,10 @@ }, "SidebarToolbar": { "19e32d0e5f": "フォルダーピッカーを開いてプロジェクトを追加します", - "abc62b6328": "プロジェクトを追加" + "abc62b6328": "プロジェクトを追加", + "87d0064026": "ワークスペースボードは下部バーに移動しました", + "a30e34eb5c": "ワークスペースボードを閉じる", + "49f62c5665": "ワークスペースボード" }, "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "デフォルトのブランチを非表示にする", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8dc2a22dc13..bb43b68764b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3332,7 +3332,8 @@ "49f62c5665": "워크스페이스 보드", "5c9c7c16aa": "워크스페이스를 만들려면 프로젝트를 추가하세요.", "ca6f729da2": "새 워크스페이스({{value0}})", - "a30e34eb5c": "워크스페이스 보드 닫기" + "a30e34eb5c": "워크스페이스 보드 닫기", + "25a95899c9": "프로젝트 추가" }, "SidebarNav": { "80611a8b10": "검색", @@ -3378,7 +3379,10 @@ }, "SidebarToolbar": { "19e32d0e5f": "폴더 선택기를 열어 프로젝트를 추가하세요.", - "abc62b6328": "프로젝트 추가" + "abc62b6328": "프로젝트 추가", + "87d0064026": "워크스페이스 보드가 하단 바로 옮겨졌습니다", + "a30e34eb5c": "워크스페이스 보드 닫기", + "49f62c5665": "워크스페이스 보드" }, "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "기본 브랜치 숨기기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 429dd821c2d..8e260bbccaa 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3332,7 +3332,8 @@ "49f62c5665": "工作区板", "5c9c7c16aa": "添加项目以创建工作区", "ca6f729da2": "新工作区 ({{value0}})", - "a30e34eb5c": "关闭工作区板" + "a30e34eb5c": "关闭工作区板", + "25a95899c9": "添加项目" }, "SidebarNav": { "80611a8b10": "搜索", @@ -3378,7 +3379,10 @@ }, "SidebarToolbar": { "19e32d0e5f": "打开文件夹选择器以添加项目", - "abc62b6328": "添加项目" + "abc62b6328": "添加项目", + "87d0064026": "工作区板已移到底部栏", + "a30e34eb5c": "关闭工作区板", + "49f62c5665": "工作区板" }, "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "隐藏默认分支", diff --git a/src/shared/terminal-custom-themes.ts b/src/shared/terminal-custom-themes.ts index af4fcdd218d..58b2e2033d2 100644 --- a/src/shared/terminal-custom-themes.ts +++ b/src/shared/terminal-custom-themes.ts @@ -1,4 +1,3 @@ -import type { ITheme } from '@xterm/xterm' import type { TerminalColorOverrides } from './types' import { HEX_COLOR_RE } from './color-validation' @@ -233,6 +232,8 @@ export function normalizeTerminalCustomThemes(value: unknown): TerminalCustomThe return [...byId.values()].slice(-MAX_TERMINAL_CUSTOM_THEMES) } -export function terminalCustomThemeToXtermTheme(theme: TerminalCustomTheme): ITheme { +export function terminalCustomThemeToXtermTheme( + theme: TerminalCustomTheme +): TerminalColorOverrides { return { ...theme.terminal } }