Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Spaces: allow empty Spaces (no last-node warning/auto-close), add pane context menu action to create an empty Space, and allow archiving a Space without saving its history. (#171)

### 🐞 Fixed
- Sidebar: stabilize Project, Space, and Agent drag sorting with scoped collision detection, consistent drag overlays, and measured upward-drag motion coverage. (#295)
- Sidebar: use a continuous shared-list animation for expanded/collapsed mode switches, keep the Space disclosure as one moving button, hide rail text cleanly, preserve list scroll position, add overflow edge fades, and keep label color context menus open for consecutive changes. (#292)
- Space Explorer: make image quick previews match the opened image node size and keep preview headers from covering image content. (#287)
- Dev: keep the launching terminal interactive after stopping `pnpm dev` with Ctrl+C by making the dev wrapper own child-process shutdown. (#286)
Expand Down
12 changes: 12 additions & 0 deletions src/app/renderer/i18n/locales/en.shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ export const enShell = {
rename: 'Rename',
save: 'Save',
cancel: 'Cancel',
projectIcon: 'Project icon',
defaultProjectIcon: 'Default folder',
icons: {
code: 'Code',
terminal: 'Terminal',
database: 'Database',
globe: 'Globe',
package: 'Package',
bot: 'Bot',
briefcase: 'Briefcase',
'book-open': 'Book',
},
manageMounts: 'Manage locations…',
openInExplorer: 'Open in Explorer',
openInFinder: 'Open in Finder',
Expand Down
12 changes: 12 additions & 0 deletions src/app/renderer/i18n/locales/zh-CN.shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ export const zhCNShell = {
rename: '重命名',
save: '保存',
cancel: '取消',
projectIcon: '项目图标',
defaultProjectIcon: '默认文件夹',
icons: {
code: '代码',
terminal: '终端',
database: '数据库',
globe: '网络',
package: '包',
bot: 'Bot',
briefcase: '公文包',
'book-open': '文档',
},
manageMounts: '管理位置…',
openInExplorer: '在资源管理器中打开',
openInFinder: '在 Finder 中打开',
Expand Down
8 changes: 5 additions & 3 deletions src/app/renderer/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ export default function App(): React.JSX.Element {
handleRequestManageProjectMounts,
handleRequestOpenProjectInFileManager,
handleReorderWorkspaces,
handleReorderWorkspaceRootSpaces,
handleReorderWorkspaceSidebarAgents,
} = useAppShellWorkspaceActions({ requestPersistFlush, t, showMessage: handleShowMessage })

useProjectContextMenuDismiss({
Expand Down Expand Up @@ -359,6 +361,8 @@ export default function App(): React.JSX.Element {
onOpenProjectContextMenu={setProjectContextMenu}
onSelectAgentNode={handleSelectAgentNode}
onReorderWorkspaces={handleReorderWorkspaces}
onReorderWorkspaceRootSpaces={handleReorderWorkspaceRootSpaces}
onReorderWorkspaceSidebarAgents={handleReorderWorkspaceSidebarAgents}
onPointerEnter={handleSidebarPointerEnter}
onPointerLeave={handleSidebarPointerLeave}
/>
Expand Down Expand Up @@ -441,9 +445,7 @@ export default function App(): React.JSX.Element {
onDeleteSpaceArchiveRecord={handleWorkspaceSpaceArchiveRecordRemove}
onCloseSpaceArchives={closeSpaceArchives}
isAddProjectWizardOpen={isAddProjectWizardOpen}
onCloseAddProjectWizard={() => {
closeAddProjectWizard()
}}
onCloseAddProjectWizard={closeAddProjectWizard}
projectContextMenu={projectContextMenu}
projectMountManager={projectMountManager}
onCloseProjectMountManager={() => {
Expand Down
10 changes: 10 additions & 0 deletions src/app/renderer/shell/components/ProjectContextMenu.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ describe('ProjectContextMenu', () => {
expect(useAppStore.getState().workspaces[0]?.name).toBe('Project Renamed')
})

it('updates a project icon from the context menu', () => {
renderMenu({ kind: 'project', workspaceId: 'workspace-a' })

fireEvent.click(screen.getByTestId('workspace-project-context-menu-icon-code'))
expect(useAppStore.getState().workspaces[0]?.iconId).toBe('code')

fireEvent.click(screen.getByTestId('workspace-project-context-menu-icon-default'))
expect(useAppStore.getState().workspaces[0]?.iconId).toBeNull()
})

it('renames a space target', () => {
renderMenu({ kind: 'space', workspaceId: 'workspace-a', spaceId: 'space-a' })

Expand Down
24 changes: 24 additions & 0 deletions src/app/renderer/shell/components/ProjectContextMenu.state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { LabelColor, NodeLabelColorOverride } from '@shared/types/labelColor'
import { normalizeProjectIconId, type ProjectIconId } from '@shared/types/projectIcon'
import {
buildAgentNodeTitle,
stripAgentProviderPrefix,
Expand Down Expand Up @@ -137,6 +138,29 @@ export function resolveTargetLabelColor(
return node.data.labelColorOverride ?? null
}

export function resolveTargetProjectIconId(
workspaces: WorkspaceState[],
target: ProjectContextMenuTarget,
): ProjectIconId | null {
if (target.kind !== 'project') {
return null
}

const workspace = workspaces.find(candidate => candidate.id === target.workspaceId) ?? null
return normalizeProjectIconId(workspace?.iconId)
}

export function setTargetProjectIconId(
target: ProjectContextMenuTarget,
iconId: ProjectIconId | null,
): void {
if (target.kind !== 'project') {
return
}

useAppStore.getState().setProjectIconId(target.workspaceId, iconId)
}

export function setTargetLabelColor(
target: ProjectContextMenuTarget,
labelColor: LabelColor | NodeLabelColorOverride | null,
Expand Down
73 changes: 70 additions & 3 deletions src/app/renderer/shell/components/ProjectContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Check, Copy, FolderOpen, FolderX, HardDrive, Pencil, X } from 'lucide-react'
import { Check, Copy, Folder, FolderOpen, FolderX, HardDrive, Pencil, X } from 'lucide-react'
import { useTranslation } from '@app/renderer/i18n'
import { ViewportMenuSurface } from '@app/renderer/components/ViewportMenuSurface'
import {
LABEL_COLORS,
type LabelColor,
type NodeLabelColorOverride,
} from '@shared/types/labelColor'
import { PROJECT_ICON_IDS, type ProjectIconId } from '@shared/types/projectIcon'
import type { WorkspaceState } from '@contexts/workspace/presentation/renderer/types'
import type { ProjectContextMenuTarget } from '../types'
import { useAppStore } from '../store/useAppStore'
import { getProjectIconComponent } from './ProjectIcon'
import {
renameTarget,
resolveTargetProjectIconId,
resolveTargetLabelColor,
resolveTargetName,
resolveTargetSpacePath,
setTargetProjectIconId,
setTargetLabelColor,
} from './ProjectContextMenu.state'

Expand Down Expand Up @@ -58,6 +62,7 @@ export function ProjectContextMenu({
const isProjectTarget = resolvedTarget.kind === 'project'
const isSpaceTarget = resolvedTarget.kind === 'space'
const supportsLabelColor = resolvedTarget.kind !== 'project'
const currentProjectIconId = resolveTargetProjectIconId(workspaces, resolvedTarget)
const currentLabelColor = resolveTargetLabelColor(workspaces, resolvedTarget)
const estimatedWidth = isRenaming
? 236
Expand All @@ -73,9 +78,9 @@ export function ProjectContextMenu({
: isSpaceTarget
? 174
: canOpenInFileManager && isProjectTarget
? 148
? 218
: isProjectTarget
? 112
? 182
: resolvedTarget.kind === 'agent'
? 124
: 102
Expand Down Expand Up @@ -120,6 +125,9 @@ export function ProjectContextMenu({
const commitLabelColor = (labelColor: LabelColor | NodeLabelColorOverride | null): void => {
setTargetLabelColor(resolvedTarget, labelColor)
}
const commitProjectIcon = (iconId: ProjectIconId | null): void => {
setTargetProjectIconId(resolvedTarget, iconId)
}
const copySpacePath = (): void => {
const path = resolveTargetSpacePath(workspaces, resolvedTarget)
useAppStore.getState().setProjectContextMenu(null)
Expand Down Expand Up @@ -227,6 +235,64 @@ export function ProjectContextMenu({
</div>
)
}
const renderProjectIconButton = ({
id,
iconId,
label,
}: {
id: string
iconId: ProjectIconId | null
label: string
}): React.JSX.Element => {
const Icon = iconId ? (getProjectIconComponent(iconId) ?? Folder) : Folder
const selected = currentProjectIconId === iconId

return (
<button
type="button"
className={`workspace-project-context-menu__project-icon-button${selected ? ' workspace-project-context-menu__project-icon-button--selected' : ''}`}
data-testid={`workspace-project-context-menu-icon-${id}`}
aria-label={label}
title={label}
onClick={() => {
commitProjectIcon(iconId)
}}
>
<Icon className="workspace-project-context-menu__project-icon" aria-hidden="true" />
{selected ? <Check className="workspace-project-context-menu__icon-check" /> : null}
</button>
)
}
const renderProjectIconRow = (): React.JSX.Element | null => {
if (!isProjectTarget) {
return null
}

return (
<div
className="workspace-project-context-menu__project-icon-section"
data-testid="workspace-project-context-menu-icons"
>
<div className="workspace-context-menu__section-title">
{t('projectContextMenu.projectIcon')}
</div>
<div className="workspace-project-context-menu__project-icon-grid">
{renderProjectIconButton({
id: 'default',
iconId: null,
label: t('projectContextMenu.defaultProjectIcon'),
})}
{PROJECT_ICON_IDS.map(iconId =>
renderProjectIconButton({
id: iconId,
iconId,
label: t(`projectContextMenu.icons.${iconId}`),
}),
)}
</div>
</div>
)
}

return (
<ViewportMenuSurface
Expand Down Expand Up @@ -322,6 +388,7 @@ export function ProjectContextMenu({
<span className="workspace-context-menu__label">{t('projectContextMenu.rename')}</span>
</button>
)}
{!isRenaming && isProjectTarget ? renderProjectIconRow() : null}
{!isRenaming && isProjectTarget ? (
<button
type="button"
Expand Down
51 changes: 51 additions & 0 deletions src/app/renderer/shell/components/ProjectIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react'
import {
BookOpen,
Bot,
Briefcase,
Code2,
Database,
Folder,
FolderOpen,
Globe,
Package,
Terminal,
type LucideIcon,
} from 'lucide-react'
import type { ProjectIconId } from '@shared/types/projectIcon'

const PROJECT_ICON_COMPONENTS: Record<ProjectIconId, LucideIcon> = {
code: Code2,
terminal: Terminal,
database: Database,
globe: Globe,
package: Package,
bot: Bot,
briefcase: Briefcase,
'book-open': BookOpen,
}

export function getProjectIconComponent(iconId: ProjectIconId | null): LucideIcon | null {
return iconId ? (PROJECT_ICON_COMPONENTS[iconId] ?? null) : null
}

export function ProjectIcon({
iconId,
isExpanded,
className,
}: {
iconId?: ProjectIconId | null
isExpanded: boolean
className?: string
}): React.JSX.Element {
const CustomIcon = getProjectIconComponent(iconId ?? null)
const Icon = CustomIcon ?? (isExpanded ? FolderOpen : Folder)

return (
<Icon
className={className}
data-cove-project-icon-id={iconId ?? 'default'}
aria-hidden="true"
/>
)
}
2 changes: 2 additions & 0 deletions src/app/renderer/shell/components/Sidebar.scroll.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import { Sidebar } from './Sidebar'
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: { children: ReactNode }) => <>{children}</>,
DragOverlay: ({ children }: { children: ReactNode }) => <>{children}</>,
MeasuringStrategy: { Always: 0 },
PointerSensor: vi.fn(),
closestCenter: vi.fn(),
pointerWithin: vi.fn(() => []),
useSensor: vi.fn((_sensor: unknown, options?: unknown) => ({ options })),
useSensors: vi.fn((...sensors: unknown[]) => sensors),
}))
Expand Down
6 changes: 2 additions & 4 deletions src/app/renderer/shell/components/Sidebar.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,13 @@ import {
type WorkspaceState,
} from '@contexts/workspace/presentation/renderer/types'
import { Sidebar } from './Sidebar'

const dndState = vi.hoisted(() => ({
draggingId: null as string | null,
onDragStart: null as ((event: { active: { id: string } }) => void) | null,
onDragEnd: null as
| ((event: { active: { id: string }; over: { id: string } | null }) => void)
| null,
}))

vi.mock('@dnd-kit/core', () => ({
DndContext: ({
children,
Expand All @@ -31,12 +29,13 @@ vi.mock('@dnd-kit/core', () => ({
return <>{children}</>
},
DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}</>,
MeasuringStrategy: { Always: 0 },
PointerSensor: vi.fn(),
closestCenter: vi.fn(),
pointerWithin: vi.fn(() => []),
useSensor: vi.fn((_sensor: unknown, options?: unknown) => ({ options })),
useSensors: vi.fn((...sensors: unknown[]) => sensors),
}))

vi.mock('@dnd-kit/sortable', () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useSortable: ({ id }: { id: string }) => ({
Expand All @@ -49,7 +48,6 @@ vi.mock('@dnd-kit/sortable', () => ({
}),
verticalListSortingStrategy: vi.fn(),
}))

vi.mock('@dnd-kit/utilities', () => ({
CSS: {
Transform: {
Expand Down
Loading
Loading