mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Improve session project sidebar controls
The session sidebar already uses project grouping, so the change adds header-level project actions instead of replacing project row controls. Sorting and organization choices are persisted through the existing desktop UI preferences file and mirrored into local cache for startup continuity. Constraint: Keep the existing project tree, drag ordering, pinning, hiding, and Finder project-row actions intact. Rejected: Replacing project row actions with the header menu | row-level Finder and per-project session actions are still useful and already covered by tests. Confidence: high Scope-risk: moderate Directive: Do not store sidebar organization in global Claude settings; keep it under cc-haha desktop-ui preferences. Tested: cd desktop && bun run test -- src/components/layout/Sidebar.test.tsx Tested: bun test src/server/__tests__/desktop-ui-preferences.test.ts Tested: cd desktop && bun run lint Tested: cd desktop && bun run build
This commit is contained in:
parent
5d9d1b009e
commit
4e969475f2
@ -4,6 +4,8 @@ export type SidebarProjectPreferences = {
|
||||
projectOrder: string[]
|
||||
pinnedProjects: string[]
|
||||
hiddenProjects: string[]
|
||||
projectOrganization: 'project' | 'recentProject' | 'time'
|
||||
projectSortBy: 'createdAt' | 'updatedAt'
|
||||
}
|
||||
|
||||
export type DesktopUiPreferences = {
|
||||
|
||||
@ -39,6 +39,19 @@ vi.mock('../../i18n', () => ({
|
||||
'sidebar.sessionListFailed': 'Session list failed',
|
||||
'sidebar.refreshSessions': 'Refresh sessions',
|
||||
'sidebar.projects': 'Projects',
|
||||
'sidebar.projectMenu': 'Project menu',
|
||||
'sidebar.newProject': 'New project',
|
||||
'sidebar.archiveAllChats': 'Archive all chats',
|
||||
'sidebar.organizeSidebar': 'Organize sidebar',
|
||||
'sidebar.sortCondition': 'Sort condition',
|
||||
'sidebar.organizeByProject': 'By project',
|
||||
'sidebar.organizeByRecentProject': 'Recent projects',
|
||||
'sidebar.organizeByTime': 'By time',
|
||||
'sidebar.sortByCreatedAt': 'Created time',
|
||||
'sidebar.sortByUpdatedAt': 'Updated time',
|
||||
'sidebar.newBlankProject': 'New blank project',
|
||||
'sidebar.useExistingFolder': 'Use existing folder',
|
||||
'sidebar.chooseProjectFolderUnavailable': 'Folder selection is only available in the desktop app.',
|
||||
'sidebar.projectActions': 'Project actions for {project}',
|
||||
'sidebar.pinProject': 'Pin Project',
|
||||
'sidebar.unpinProject': 'Unpin Project',
|
||||
@ -100,6 +113,8 @@ import type { SessionListItem } from '../../types/session'
|
||||
const PROJECT_ORDER_STORAGE_KEY = 'cc-haha-sidebar-project-order'
|
||||
const PROJECT_PINNED_STORAGE_KEY = 'cc-haha-sidebar-pinned-projects'
|
||||
const PROJECT_HIDDEN_STORAGE_KEY = 'cc-haha-sidebar-hidden-projects'
|
||||
const PROJECT_ORGANIZATION_STORAGE_KEY = 'cc-haha-sidebar-project-organization'
|
||||
const PROJECT_SORT_STORAGE_KEY = 'cc-haha-sidebar-project-sort'
|
||||
|
||||
function makeSession(
|
||||
id: string,
|
||||
@ -170,6 +185,8 @@ describe('Sidebar', () => {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: [],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -179,6 +196,8 @@ describe('Sidebar', () => {
|
||||
window.localStorage.removeItem(PROJECT_ORDER_STORAGE_KEY)
|
||||
window.localStorage.removeItem(PROJECT_PINNED_STORAGE_KEY)
|
||||
window.localStorage.removeItem(PROJECT_HIDDEN_STORAGE_KEY)
|
||||
window.localStorage.removeItem(PROJECT_ORGANIZATION_STORAGE_KEY)
|
||||
window.localStorage.removeItem(PROJECT_SORT_STORAGE_KEY)
|
||||
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useSessionStore.setState({
|
||||
@ -212,6 +231,8 @@ describe('Sidebar', () => {
|
||||
window.localStorage.removeItem(PROJECT_ORDER_STORAGE_KEY)
|
||||
window.localStorage.removeItem(PROJECT_PINNED_STORAGE_KEY)
|
||||
window.localStorage.removeItem(PROJECT_HIDDEN_STORAGE_KEY)
|
||||
window.localStorage.removeItem(PROJECT_ORGANIZATION_STORAGE_KEY)
|
||||
window.localStorage.removeItem(PROJECT_SORT_STORAGE_KEY)
|
||||
})
|
||||
|
||||
it('opens a new tab when creating a session from the sidebar', async () => {
|
||||
@ -392,6 +413,82 @@ describe('Sidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows project header menus and starts a blank project session', async () => {
|
||||
createSession.mockResolvedValue('session-blank-project')
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString()),
|
||||
],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(screen.getByTestId('sidebar-projects-header')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
|
||||
expect(screen.getByRole('menuitem', { name: 'New blank project' })).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'New blank project' }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSession).toHaveBeenCalledWith(undefined)
|
||||
expect(connectToSession).toHaveBeenCalledWith('session-blank-project')
|
||||
})
|
||||
})
|
||||
|
||||
it('persists project header sort preferences through desktop UI settings', async () => {
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', '2026-03-01T00:00:00.000Z'),
|
||||
{
|
||||
...makeSession('beta-1', 'Beta Session', '/workspace/beta', '2026-02-01T00:00:00.000Z'),
|
||||
createdAt: '2026-04-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(projectGroupNames().slice(0, 2)).toEqual(['alpha', 'beta'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project menu' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Sort condition' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Created time' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: [],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'createdAt',
|
||||
})
|
||||
expect(projectGroupNames().slice(0, 2)).toEqual(['beta', 'alpha'])
|
||||
})
|
||||
expect(window.localStorage.getItem(PROJECT_SORT_STORAGE_KEY)).toBe('createdAt')
|
||||
})
|
||||
|
||||
it('uses the project header menu to request archiving all visible sessions', () => {
|
||||
const now = new Date().toISOString()
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now),
|
||||
makeSession('beta-1', 'Beta Session', '/workspace/beta', now),
|
||||
],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project menu' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Archive all chats' }))
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(within(dialog).getByText('Delete 2 sessions? This cannot be undone.')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('Alpha Session')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('Beta Session')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps project row actions hidden until project hover or focus', () => {
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
@ -512,6 +609,8 @@ describe('Sidebar', () => {
|
||||
projectOrder: ['/workspace/beta', '/workspace/alpha'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: ['/workspace/alpha'],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -546,6 +645,8 @@ describe('Sidebar', () => {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: [],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -564,6 +665,8 @@ describe('Sidebar', () => {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: ['/workspace/beta'],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText('beta')).not.toBeInTheDocument()
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
import { ChevronDown, Folder, FolderOpen, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react'
|
||||
import { Archive, Check, ChevronDown, Clock, Folder, FolderOpen, FolderPlus, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@ -18,9 +18,15 @@ const SESSION_LIST_FOCUS_REFRESH_MIN_MS = 5_000
|
||||
const PROJECT_ORDER_STORAGE_KEY = 'cc-haha-sidebar-project-order'
|
||||
const PROJECT_PINNED_STORAGE_KEY = 'cc-haha-sidebar-pinned-projects'
|
||||
const PROJECT_HIDDEN_STORAGE_KEY = 'cc-haha-sidebar-hidden-projects'
|
||||
const PROJECT_ORGANIZATION_STORAGE_KEY = 'cc-haha-sidebar-project-organization'
|
||||
const PROJECT_SORT_STORAGE_KEY = 'cc-haha-sidebar-project-sort'
|
||||
const PROJECT_GROUP_VISIBLE_COUNT = 6
|
||||
const PROJECT_GROUP_SCROLL_COUNT = 12
|
||||
|
||||
type SidebarProjectOrganization = 'project' | 'recentProject' | 'time'
|
||||
type SidebarProjectSortBy = 'createdAt' | 'updatedAt'
|
||||
type SidebarHeaderMenuType = 'main' | 'organize' | 'sort' | 'create'
|
||||
|
||||
type ProjectGroup = {
|
||||
key: string
|
||||
title: string
|
||||
@ -60,6 +66,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
|
||||
const [projectContextMenu, setProjectContextMenu] = useState<{ key: string; x: number; y: number } | null>(null)
|
||||
const [projectHeaderMenu, setProjectHeaderMenu] = useState<{ type: SidebarHeaderMenuType; x: number; y: number } | null>(null)
|
||||
const [projectHeaderSubmenu, setProjectHeaderSubmenu] = useState<{ type: 'organize' | 'sort'; x: number; y: number } | null>(null)
|
||||
const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState<string | null>(null)
|
||||
const [pendingBatchDeleteSessionIds, setPendingBatchDeleteSessionIds] = useState<string[] | null>(null)
|
||||
const [isBatchDeleting, setIsBatchDeleting] = useState(false)
|
||||
@ -71,6 +79,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
const [projectOrder, setProjectOrder] = useState<string[]>(() => readStoredProjectOrder())
|
||||
const [pinnedProjectKeys, setPinnedProjectKeys] = useState<Set<string>>(() => readStoredProjectPins())
|
||||
const [hiddenProjectKeys, setHiddenProjectKeys] = useState<Set<string>>(() => readStoredProjectHidden())
|
||||
const [projectOrganization, setProjectOrganizationState] = useState<SidebarProjectOrganization>(() => readStoredProjectOrganization())
|
||||
const [projectSortBy, setProjectSortByState] = useState<SidebarProjectSortBy>(() => readStoredProjectSortBy())
|
||||
const [draggingProjectKey, setDraggingProjectKey] = useState<string | null>(null)
|
||||
const [projectDropTarget, setProjectDropTarget] = useState<{ key: string; position: 'before' | 'after' } | null>(null)
|
||||
const suppressProjectClickRef = useRef<string | null>(null)
|
||||
@ -84,14 +94,16 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
}, [sidebarOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu && !projectContextMenu) return
|
||||
if (!contextMenu && !projectContextMenu && !projectHeaderMenu && !projectHeaderSubmenu) return
|
||||
const close = () => {
|
||||
setContextMenu(null)
|
||||
setProjectContextMenu(null)
|
||||
setProjectHeaderMenu(null)
|
||||
setProjectHeaderSubmenu(null)
|
||||
}
|
||||
document.addEventListener('click', close)
|
||||
return () => document.removeEventListener('click', close)
|
||||
}, [contextMenu, projectContextMenu])
|
||||
}, [contextMenu, projectContextMenu, projectHeaderMenu, projectHeaderSubmenu])
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
let result = sessions
|
||||
@ -105,10 +117,10 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
return result
|
||||
}, [sessions, selectedProjects, searchQuery])
|
||||
|
||||
const projectGroups = useMemo(() => groupByProject(filteredSessions), [filteredSessions])
|
||||
const projectGroups = useMemo(() => groupByProject(filteredSessions, projectSortBy), [filteredSessions, projectSortBy])
|
||||
const orderedProjectGroups = useMemo(
|
||||
() => applyProjectOrder(projectGroups, projectOrder, pinnedProjectKeys),
|
||||
[projectGroups, projectOrder, pinnedProjectKeys],
|
||||
() => applyProjectOrder(projectGroups, projectOrder, pinnedProjectKeys, projectOrganization, projectSortBy),
|
||||
[projectGroups, projectOrder, pinnedProjectKeys, projectOrganization, projectSortBy],
|
||||
)
|
||||
const visibleProjectGroups = useMemo(() => {
|
||||
if (hiddenProjectKeys.size === 0) return orderedProjectGroups
|
||||
@ -138,6 +150,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
setProjectOrder(preferences.projectOrder)
|
||||
setPinnedProjectKeys(new Set(preferences.pinnedProjects))
|
||||
setHiddenProjectKeys(new Set(preferences.hiddenProjects))
|
||||
setProjectOrganizationState(preferences.projectOrganization)
|
||||
setProjectSortByState(preferences.projectSortBy)
|
||||
}, [])
|
||||
|
||||
const persistSidebarProjectPreferences = useCallback((preferences: SidebarProjectPreferences) => {
|
||||
@ -231,9 +245,9 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
dropPosition,
|
||||
)
|
||||
setProjectOrder(nextOrder)
|
||||
persistSidebarProjectPreferences(buildSidebarProjectPreferences(nextOrder, pinnedProjectKeys, hiddenProjectKeys))
|
||||
persistSidebarProjectPreferences(buildSidebarProjectPreferences(nextOrder, pinnedProjectKeys, hiddenProjectKeys, projectOrganization, projectSortBy))
|
||||
clearProjectDragState()
|
||||
}, [clearProjectDragState, draggingProjectKey, hiddenProjectKeys, orderedProjectGroups, persistSidebarProjectPreferences, pinnedProjectKeys, projectDropTarget])
|
||||
}, [clearProjectDragState, draggingProjectKey, hiddenProjectKeys, orderedProjectGroups, persistSidebarProjectPreferences, pinnedProjectKeys, projectDropTarget, projectOrganization, projectSortBy])
|
||||
|
||||
const createSessionForWorkDir = useCallback(async (workDir?: string) => {
|
||||
try {
|
||||
@ -249,6 +263,89 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
}
|
||||
}, [addToast, closeMobileDrawer, t])
|
||||
|
||||
const openProjectHeaderMenu = useCallback((event: React.MouseEvent, type: SidebarHeaderMenuType) => {
|
||||
event.stopPropagation()
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const width = type === 'create' ? 250 : 270
|
||||
setProjectContextMenu(null)
|
||||
setContextMenu(null)
|
||||
setProjectHeaderSubmenu(null)
|
||||
setProjectHeaderMenu({
|
||||
type,
|
||||
x: Math.max(10, Math.min(rect.right - width, window.innerWidth - width - 10)),
|
||||
y: rect.bottom + 8,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const openProjectHeaderSubmenu = useCallback((event: React.MouseEvent, type: 'organize' | 'sort') => {
|
||||
event.stopPropagation()
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const width = type === 'sort' ? 230 : 260
|
||||
setProjectHeaderSubmenu({
|
||||
type,
|
||||
x: Math.max(10, Math.min(rect.right + 8, window.innerWidth - width - 10)),
|
||||
y: Math.max(10, Math.min(rect.top - 8, window.innerHeight - 170)),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateProjectOrganization = useCallback((organization: SidebarProjectOrganization) => {
|
||||
setProjectHeaderMenu(null)
|
||||
setProjectHeaderSubmenu(null)
|
||||
setProjectOrganizationState(organization)
|
||||
const nextOrder = organization === 'project' || organization === 'time' ? [] : projectOrder
|
||||
if (nextOrder !== projectOrder) setProjectOrder(nextOrder)
|
||||
persistSidebarProjectPreferences(buildSidebarProjectPreferences(
|
||||
nextOrder,
|
||||
pinnedProjectKeys,
|
||||
hiddenProjectKeys,
|
||||
organization,
|
||||
projectSortBy,
|
||||
))
|
||||
}, [hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectSortBy])
|
||||
|
||||
const updateProjectSortBy = useCallback((sortBy: SidebarProjectSortBy) => {
|
||||
setProjectHeaderMenu(null)
|
||||
setProjectHeaderSubmenu(null)
|
||||
setProjectSortByState(sortBy)
|
||||
const nextOrder: string[] = []
|
||||
setProjectOrder(nextOrder)
|
||||
persistSidebarProjectPreferences(buildSidebarProjectPreferences(
|
||||
nextOrder,
|
||||
pinnedProjectKeys,
|
||||
hiddenProjectKeys,
|
||||
projectOrganization,
|
||||
sortBy,
|
||||
))
|
||||
}, [hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrganization])
|
||||
|
||||
const createSessionFromExistingFolder = useCallback(async () => {
|
||||
setProjectHeaderMenu(null)
|
||||
setProjectHeaderSubmenu(null)
|
||||
if (!isTauri) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: t('sidebar.chooseProjectFolderUnavailable'),
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t('sidebar.useExistingFolder'),
|
||||
})
|
||||
if (typeof selected === 'string' && selected.trim()) {
|
||||
await createSessionForWorkDir(selected)
|
||||
}
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
|
||||
})
|
||||
}
|
||||
}, [addToast, createSessionForWorkDir, t])
|
||||
|
||||
const togglePinnedProject = useCallback((projectKey: string) => {
|
||||
setProjectContextMenu(null)
|
||||
setPinnedProjectKeys((current) => {
|
||||
@ -258,10 +355,10 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
} else {
|
||||
next.add(projectKey)
|
||||
}
|
||||
persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, next, hiddenProjectKeys))
|
||||
persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, next, hiddenProjectKeys, projectOrganization, projectSortBy))
|
||||
return next
|
||||
})
|
||||
}, [hiddenProjectKeys, persistSidebarProjectPreferences, projectOrder])
|
||||
}, [hiddenProjectKeys, persistSidebarProjectPreferences, projectOrder, projectOrganization, projectSortBy])
|
||||
|
||||
const toggleHiddenProject = useCallback((project: ProjectGroup) => {
|
||||
const wasHidden = hiddenProjectKeys.has(project.key)
|
||||
@ -273,7 +370,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
} else {
|
||||
next.add(project.key)
|
||||
}
|
||||
persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, pinnedProjectKeys, next))
|
||||
persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, pinnedProjectKeys, next, projectOrganization, projectSortBy))
|
||||
return next
|
||||
})
|
||||
if (!wasHidden) {
|
||||
@ -282,7 +379,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
message: t('sidebar.projectHidden', { project: project.title }),
|
||||
})
|
||||
}
|
||||
}, [addToast, hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, t])
|
||||
}, [addToast, hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy, t])
|
||||
|
||||
const openProjectInFinder = useCallback(async (project: ProjectGroup) => {
|
||||
setProjectContextMenu(null)
|
||||
@ -347,6 +444,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
setPendingBatchDeleteSessionIds([...new Set(ids)])
|
||||
}, [])
|
||||
|
||||
const requestArchiveAllVisibleSessions = useCallback(() => {
|
||||
setProjectHeaderMenu(null)
|
||||
setProjectHeaderSubmenu(null)
|
||||
requestBatchDelete(filteredSessionIds)
|
||||
}, [filteredSessionIds, requestBatchDelete])
|
||||
|
||||
const confirmBatchDelete = useCallback(async () => {
|
||||
const ids = pendingBatchDeleteSessionIds ?? []
|
||||
if (ids.length === 0) return
|
||||
@ -684,9 +787,13 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
</div>
|
||||
)}
|
||||
{visibleProjectGroups.length > 0 && (
|
||||
<div className="px-1.5 pb-2 pt-1 text-[12px] font-semibold tracking-normal text-[var(--color-text-primary)]">
|
||||
{t('sidebar.projects')}
|
||||
</div>
|
||||
<ProjectHeaderActions
|
||||
title={t('sidebar.projects')}
|
||||
menuLabel={t('sidebar.projectMenu')}
|
||||
createLabel={t('sidebar.newProject')}
|
||||
onOpenMenu={(event) => openProjectHeaderMenu(event, 'main')}
|
||||
onOpenCreate={(event) => openProjectHeaderMenu(event, 'create')}
|
||||
/>
|
||||
)}
|
||||
{visibleProjectGroups.map((project) => {
|
||||
const projectCollapsed = collapsedProjectKeys.has(project.key)
|
||||
@ -987,6 +1094,40 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
)
|
||||
})()}
|
||||
|
||||
{projectHeaderMenu && (
|
||||
<ProjectHeaderMenu
|
||||
type={projectHeaderMenu.type}
|
||||
x={projectHeaderMenu.x}
|
||||
y={projectHeaderMenu.y}
|
||||
organization={projectOrganization}
|
||||
sortBy={projectSortBy}
|
||||
onArchiveAll={requestArchiveAllVisibleSessions}
|
||||
onOpenSubmenu={openProjectHeaderSubmenu}
|
||||
onSetOrganization={updateProjectOrganization}
|
||||
onSetSortBy={updateProjectSortBy}
|
||||
onCreateBlank={() => void createSessionForWorkDir()}
|
||||
onUseExistingFolder={() => void createSessionFromExistingFolder()}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{projectHeaderSubmenu && (
|
||||
<ProjectHeaderMenu
|
||||
type={projectHeaderSubmenu.type}
|
||||
x={projectHeaderSubmenu.x}
|
||||
y={projectHeaderSubmenu.y}
|
||||
organization={projectOrganization}
|
||||
sortBy={projectSortBy}
|
||||
onArchiveAll={requestArchiveAllVisibleSessions}
|
||||
onOpenSubmenu={openProjectHeaderSubmenu}
|
||||
onSetOrganization={updateProjectOrganization}
|
||||
onSetSortBy={updateProjectSortBy}
|
||||
onCreateBlank={() => void createSessionForWorkDir()}
|
||||
onUseExistingFolder={() => void createSessionFromExistingFolder()}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDeleteSessionId !== null}
|
||||
onClose={() => setPendingDeleteSessionId(null)}
|
||||
@ -1091,7 +1232,186 @@ function isDocumentVisible(): boolean {
|
||||
return typeof document === 'undefined' || document.visibilityState !== 'hidden'
|
||||
}
|
||||
|
||||
function groupByProject(sessions: SessionListItem[]): ProjectGroup[] {
|
||||
function ProjectHeaderActions({
|
||||
title,
|
||||
menuLabel,
|
||||
createLabel,
|
||||
onOpenMenu,
|
||||
onOpenCreate,
|
||||
}: {
|
||||
title: string
|
||||
menuLabel: string
|
||||
createLabel: string
|
||||
onOpenMenu: (event: React.MouseEvent) => void
|
||||
onOpenCreate: (event: React.MouseEvent) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="sidebar-projects-header"
|
||||
className="group/sidebar-projects flex items-center justify-between px-1.5 pb-2 pt-1"
|
||||
>
|
||||
<div className="text-[12px] font-semibold tracking-normal text-[var(--color-text-primary)]">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover/sidebar-projects:opacity-100 focus-within:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMenu}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
aria-label={menuLabel}
|
||||
title={menuLabel}
|
||||
>
|
||||
<MoreHorizontal className="h-[18px] w-[18px]" strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreate}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
aria-label={createLabel}
|
||||
title={createLabel}
|
||||
>
|
||||
<FolderPlus className="h-[18px] w-[18px]" strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectHeaderMenu({
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
organization,
|
||||
sortBy,
|
||||
onArchiveAll,
|
||||
onOpenSubmenu,
|
||||
onSetOrganization,
|
||||
onSetSortBy,
|
||||
onCreateBlank,
|
||||
onUseExistingFolder,
|
||||
t,
|
||||
}: {
|
||||
type: SidebarHeaderMenuType
|
||||
x: number
|
||||
y: number
|
||||
organization: SidebarProjectOrganization
|
||||
sortBy: SidebarProjectSortBy
|
||||
onArchiveAll: () => void
|
||||
onOpenSubmenu: (event: React.MouseEvent, type: 'organize' | 'sort') => void
|
||||
onSetOrganization: (organization: SidebarProjectOrganization) => void
|
||||
onSetSortBy: (sortBy: SidebarProjectSortBy) => void
|
||||
onCreateBlank: () => void
|
||||
onUseExistingFolder: () => void
|
||||
t: ReturnType<typeof useTranslation>
|
||||
}) {
|
||||
const width = type === 'sort' ? 230 : type === 'create' ? 250 : 270
|
||||
const style: React.CSSProperties = { left: x, top: y, width, boxShadow: 'var(--shadow-dropdown)' }
|
||||
const className = 'fixed z-50 overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]'
|
||||
|
||||
if (type === 'create') {
|
||||
return (
|
||||
<div role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
||||
<HeaderMenuItem icon={<SquarePen size={18} aria-hidden="true" />} onClick={onCreateBlank}>
|
||||
{t('sidebar.newBlankProject')}
|
||||
</HeaderMenuItem>
|
||||
<HeaderMenuItem icon={<FolderOpen size={18} aria-hidden="true" />} onClick={onUseExistingFolder}>
|
||||
{t('sidebar.useExistingFolder')}
|
||||
</HeaderMenuItem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'organize') {
|
||||
return (
|
||||
<div role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
||||
<HeaderMenuItem icon={<Folder size={18} aria-hidden="true" />} checked={organization === 'project'} onClick={() => onSetOrganization('project')}>
|
||||
{t('sidebar.organizeByProject')}
|
||||
</HeaderMenuItem>
|
||||
<HeaderMenuItem icon={<FolderOpen size={18} aria-hidden="true" />} checked={organization === 'recentProject'} onClick={() => onSetOrganization('recentProject')}>
|
||||
{t('sidebar.organizeByRecentProject')}
|
||||
</HeaderMenuItem>
|
||||
<HeaderMenuItem icon={<Clock size={18} aria-hidden="true" />} checked={organization === 'time'} onClick={() => onSetOrganization('time')}>
|
||||
{t('sidebar.organizeByTime')}
|
||||
</HeaderMenuItem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'sort') {
|
||||
return (
|
||||
<div role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
||||
<HeaderMenuItem icon={<Clock size={18} aria-hidden="true" />} checked={sortBy === 'createdAt'} onClick={() => onSetSortBy('createdAt')}>
|
||||
{t('sidebar.sortByCreatedAt')}
|
||||
</HeaderMenuItem>
|
||||
<HeaderMenuItem icon={<RefreshCw size={18} aria-hidden="true" />} checked={sortBy === 'updatedAt'} onClick={() => onSetSortBy('updatedAt')}>
|
||||
{t('sidebar.sortByUpdatedAt')}
|
||||
</HeaderMenuItem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
||||
<HeaderMenuItem icon={<Archive size={18} aria-hidden="true" />} onClick={onArchiveAll}>
|
||||
{t('sidebar.archiveAllChats')}
|
||||
</HeaderMenuItem>
|
||||
<div className="mx-4 my-1.5 border-t border-[var(--color-border)]" />
|
||||
<HeaderMenuItem
|
||||
icon={<Folder size={18} aria-hidden="true" />}
|
||||
trailing
|
||||
onMouseEnter={(event) => onOpenSubmenu(event, 'organize')}
|
||||
onClick={(event) => onOpenSubmenu(event, 'organize')}
|
||||
>
|
||||
{t('sidebar.organizeSidebar')}
|
||||
</HeaderMenuItem>
|
||||
<HeaderMenuItem
|
||||
icon={<Clock size={18} aria-hidden="true" />}
|
||||
trailing
|
||||
onMouseEnter={(event) => onOpenSubmenu(event, 'sort')}
|
||||
onClick={(event) => onOpenSubmenu(event, 'sort')}
|
||||
>
|
||||
{t('sidebar.sortCondition')}
|
||||
</HeaderMenuItem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderMenuItem({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
checked = false,
|
||||
trailing = false,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
onMouseEnter?: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
checked?: boolean
|
||||
trailing?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-semibold text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-[var(--color-text-secondary)]">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{children}</span>
|
||||
{checked && <Check className="h-[17px] w-[17px] text-[var(--color-text-secondary)]" strokeWidth={2} aria-hidden="true" />}
|
||||
{trailing && !checked && (
|
||||
<ChevronDown className="-rotate-90 h-[17px] w-[17px] text-[var(--color-text-tertiary)]" strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function groupByProject(sessions: SessionListItem[], sortBy: SidebarProjectSortBy): ProjectGroup[] {
|
||||
const groupsByKey = new Map<string, SessionListItem[]>()
|
||||
for (const session of sessions) {
|
||||
const key = getSessionProjectKey(session)
|
||||
@ -1101,7 +1421,7 @@ function groupByProject(sessions: SessionListItem[]): ProjectGroup[] {
|
||||
}
|
||||
|
||||
const groups = [...groupsByKey.entries()].map(([key, items]) => {
|
||||
const sortedSessions = [...items].sort(compareSessionsByModifiedAt)
|
||||
const sortedSessions = [...items].sort((a, b) => compareSessionsByTimestamp(a, b, sortBy))
|
||||
const newest = sortedSessions[0]
|
||||
const projectRoot = newest?.projectRoot || newest?.workDir || key
|
||||
return {
|
||||
@ -1113,25 +1433,28 @@ function groupByProject(sessions: SessionListItem[]): ProjectGroup[] {
|
||||
}
|
||||
})
|
||||
|
||||
return groups.sort((a, b) => compareSessionsByModifiedAt(a.sessions[0], b.sessions[0]))
|
||||
return groups.sort((a, b) => compareSessionsByTimestamp(a.sessions[0], b.sessions[0], sortBy))
|
||||
}
|
||||
|
||||
function applyProjectOrder(
|
||||
groups: ProjectGroup[],
|
||||
projectOrder: string[],
|
||||
pinnedProjectKeys: Set<string>,
|
||||
organization: SidebarProjectOrganization,
|
||||
sortBy: SidebarProjectSortBy,
|
||||
): ProjectGroup[] {
|
||||
const orderIndex = new Map(projectOrder.map((key, index) => [key, index]))
|
||||
return [...groups].sort((a, b) => {
|
||||
const aPinned = pinnedProjectKeys.has(a.key)
|
||||
const bPinned = pinnedProjectKeys.has(b.key)
|
||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
||||
if (organization === 'project') return a.title.localeCompare(b.title)
|
||||
const aIndex = orderIndex.get(a.key)
|
||||
const bIndex = orderIndex.get(b.key)
|
||||
if (aIndex !== undefined && bIndex !== undefined) return aIndex - bIndex
|
||||
if (aIndex !== undefined) return -1
|
||||
if (bIndex !== undefined) return 1
|
||||
return compareSessionsByModifiedAt(a.sessions[0], b.sessions[0])
|
||||
return compareSessionsByTimestamp(a.sessions[0], b.sessions[0], sortBy)
|
||||
})
|
||||
}
|
||||
|
||||
@ -1214,15 +1537,47 @@ function writeStoredProjectHidden(projectKeys: Set<string>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredProjectOrganization(): SidebarProjectOrganization {
|
||||
if (typeof localStorage === 'undefined') return 'recentProject'
|
||||
return normalizeProjectOrganization(localStorage.getItem(PROJECT_ORGANIZATION_STORAGE_KEY))
|
||||
}
|
||||
|
||||
function writeStoredProjectOrganization(organization: SidebarProjectOrganization): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(PROJECT_ORGANIZATION_STORAGE_KEY, organization)
|
||||
} catch {
|
||||
// Sidebar organization is a UI preference; ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredProjectSortBy(): SidebarProjectSortBy {
|
||||
if (typeof localStorage === 'undefined') return 'updatedAt'
|
||||
return normalizeProjectSortBy(localStorage.getItem(PROJECT_SORT_STORAGE_KEY))
|
||||
}
|
||||
|
||||
function writeStoredProjectSortBy(sortBy: SidebarProjectSortBy): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(PROJECT_SORT_STORAGE_KEY, sortBy)
|
||||
} catch {
|
||||
// Sidebar sorting is a UI preference; ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
function buildSidebarProjectPreferences(
|
||||
projectOrder: string[],
|
||||
pinnedProjectKeys: Set<string>,
|
||||
hiddenProjectKeys: Set<string>,
|
||||
projectOrganization: SidebarProjectOrganization,
|
||||
projectSortBy: SidebarProjectSortBy,
|
||||
): SidebarProjectPreferences {
|
||||
return normalizeSidebarProjectPreferences({
|
||||
projectOrder,
|
||||
pinnedProjects: [...pinnedProjectKeys],
|
||||
hiddenProjects: [...hiddenProjectKeys],
|
||||
projectOrganization,
|
||||
projectSortBy,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1231,6 +1586,8 @@ function readCachedSidebarProjectPreferences(): SidebarProjectPreferences {
|
||||
projectOrder: readStoredProjectOrder(),
|
||||
pinnedProjects: [...readStoredProjectPins()],
|
||||
hiddenProjects: [...readStoredProjectHidden()],
|
||||
projectOrganization: readStoredProjectOrganization(),
|
||||
projectSortBy: readStoredProjectSortBy(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1239,6 +1596,8 @@ function writeCachedSidebarProjectPreferences(preferences: SidebarProjectPrefere
|
||||
writeStoredProjectOrder(normalized.projectOrder)
|
||||
writeStoredProjectPins(new Set(normalized.pinnedProjects))
|
||||
writeStoredProjectHidden(new Set(normalized.hiddenProjects))
|
||||
writeStoredProjectOrganization(normalized.projectOrganization)
|
||||
writeStoredProjectSortBy(normalized.projectSortBy)
|
||||
}
|
||||
|
||||
function normalizeSidebarProjectPreferences(preferences: Partial<SidebarProjectPreferences> | undefined): SidebarProjectPreferences {
|
||||
@ -1246,6 +1605,8 @@ function normalizeSidebarProjectPreferences(preferences: Partial<SidebarProjectP
|
||||
projectOrder: normalizeProjectKeyList(preferences?.projectOrder),
|
||||
pinnedProjects: normalizeProjectKeyList(preferences?.pinnedProjects),
|
||||
hiddenProjects: normalizeProjectKeyList(preferences?.hiddenProjects),
|
||||
projectOrganization: normalizeProjectOrganization(preferences?.projectOrganization),
|
||||
projectSortBy: normalizeProjectSortBy(preferences?.projectSortBy),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1267,6 +1628,16 @@ function hasSidebarProjectPreferences(preferences: SidebarProjectPreferences): b
|
||||
return preferences.projectOrder.length > 0
|
||||
|| preferences.pinnedProjects.length > 0
|
||||
|| preferences.hiddenProjects.length > 0
|
||||
|| preferences.projectOrganization !== 'recentProject'
|
||||
|| preferences.projectSortBy !== 'updatedAt'
|
||||
}
|
||||
|
||||
function normalizeProjectOrganization(value: unknown): SidebarProjectOrganization {
|
||||
return value === 'project' || value === 'recentProject' || value === 'time' ? value : 'recentProject'
|
||||
}
|
||||
|
||||
function normalizeProjectSortBy(value: unknown): SidebarProjectSortBy {
|
||||
return value === 'createdAt' || value === 'updatedAt' ? value : 'updatedAt'
|
||||
}
|
||||
|
||||
function getVisibleProjectSessions(
|
||||
@ -1287,11 +1658,18 @@ function getSessionProjectKey(session: SessionListItem): string {
|
||||
return session.projectRoot || session.workDir || session.projectPath || 'unknown'
|
||||
}
|
||||
|
||||
function compareSessionsByModifiedAt(
|
||||
function compareSessionsByTimestamp(
|
||||
a: SessionListItem | undefined,
|
||||
b: SessionListItem | undefined,
|
||||
sortBy: SidebarProjectSortBy,
|
||||
): number {
|
||||
return new Date(b?.modifiedAt ?? 0).getTime() - new Date(a?.modifiedAt ?? 0).getTime()
|
||||
return getSessionTimestamp(b, sortBy) - getSessionTimestamp(a, sortBy)
|
||||
}
|
||||
|
||||
function getSessionTimestamp(session: SessionListItem | undefined, sortBy: SidebarProjectSortBy): number {
|
||||
const value = sortBy === 'createdAt' ? session?.createdAt : session?.modifiedAt
|
||||
const timestamp = new Date(value ?? 0).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : 0
|
||||
}
|
||||
|
||||
function projectTitle(pathLike: string | null | undefined): string {
|
||||
|
||||
@ -27,6 +27,19 @@ export const en = {
|
||||
'sidebar.sessionListFailed': 'Session list failed to load',
|
||||
'sidebar.refreshSessions': 'Refresh sessions',
|
||||
'sidebar.projects': 'Projects',
|
||||
'sidebar.projectMenu': 'Project menu',
|
||||
'sidebar.newProject': 'New project',
|
||||
'sidebar.archiveAllChats': 'Archive all chats',
|
||||
'sidebar.organizeSidebar': 'Organize sidebar',
|
||||
'sidebar.sortCondition': 'Sort condition',
|
||||
'sidebar.organizeByProject': 'By project',
|
||||
'sidebar.organizeByRecentProject': 'Recent projects',
|
||||
'sidebar.organizeByTime': 'By time',
|
||||
'sidebar.sortByCreatedAt': 'Created time',
|
||||
'sidebar.sortByUpdatedAt': 'Updated time',
|
||||
'sidebar.newBlankProject': 'New blank project',
|
||||
'sidebar.useExistingFolder': 'Use existing folder',
|
||||
'sidebar.chooseProjectFolderUnavailable': 'Folder selection is only available in the desktop app.',
|
||||
'sidebar.projectActions': 'Project actions for {project}',
|
||||
'sidebar.pinProject': 'Pin Project',
|
||||
'sidebar.unpinProject': 'Unpin Project',
|
||||
|
||||
@ -29,6 +29,19 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'sidebar.sessionListFailed': '会话列表加载失败',
|
||||
'sidebar.refreshSessions': '刷新会话列表',
|
||||
'sidebar.projects': '项目',
|
||||
'sidebar.projectMenu': '项目菜单',
|
||||
'sidebar.newProject': '新建项目',
|
||||
'sidebar.archiveAllChats': '归档所有聊天',
|
||||
'sidebar.organizeSidebar': '整理侧边栏',
|
||||
'sidebar.sortCondition': '排序条件',
|
||||
'sidebar.organizeByProject': '按项目',
|
||||
'sidebar.organizeByRecentProject': '近期项目',
|
||||
'sidebar.organizeByTime': '按时间顺序',
|
||||
'sidebar.sortByCreatedAt': '创建时间',
|
||||
'sidebar.sortByUpdatedAt': '更新时间',
|
||||
'sidebar.newBlankProject': '新建空白项目',
|
||||
'sidebar.useExistingFolder': '使用现有文件夹',
|
||||
'sidebar.chooseProjectFolderUnavailable': '选择文件夹仅在桌面端可用。',
|
||||
'sidebar.projectActions': '{project} 项目操作',
|
||||
'sidebar.pinProject': '置顶项目',
|
||||
'sidebar.unpinProject': '取消置顶项目',
|
||||
|
||||
@ -60,6 +60,8 @@ describe('DesktopUiPreferencesService', () => {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: [],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
},
|
||||
})
|
||||
})
|
||||
@ -95,6 +97,8 @@ describe('DesktopUiPreferencesService', () => {
|
||||
projectOrder: ['/workspace/alpha', '/workspace/beta'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: ['/workspace/gamma'],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
},
|
||||
})
|
||||
expect(after).toEqual({
|
||||
@ -104,6 +108,8 @@ describe('DesktopUiPreferencesService', () => {
|
||||
projectOrder: ['/workspace/gamma'],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: ['/workspace/beta'],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
},
|
||||
})
|
||||
expect(await readDesktopUiFile()).toEqual(after)
|
||||
@ -132,6 +138,8 @@ describe('desktop UI preferences API', () => {
|
||||
projectOrder: ['/workspace/beta', '/workspace/alpha'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: ['/workspace/old'],
|
||||
projectOrganization: 'project',
|
||||
projectSortBy: 'createdAt',
|
||||
})
|
||||
|
||||
const putRes = await handleDesktopUiApi(putReq.req, putReq.url, putReq.segments)
|
||||
@ -146,6 +154,8 @@ describe('desktop UI preferences API', () => {
|
||||
projectOrder: ['/workspace/beta', '/workspace/alpha'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: ['/workspace/old'],
|
||||
projectOrganization: 'project',
|
||||
projectSortBy: 'createdAt',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -163,6 +173,8 @@ describe('desktop UI preferences API', () => {
|
||||
projectOrder: ['/workspace/beta', '/workspace/alpha'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: ['/workspace/old'],
|
||||
projectOrganization: 'project',
|
||||
projectSortBy: 'createdAt',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@ -13,6 +13,8 @@ export type SidebarProjectPreferences = {
|
||||
projectOrder: string[]
|
||||
pinnedProjects: string[]
|
||||
hiddenProjects: string[]
|
||||
projectOrganization: 'project' | 'recentProject' | 'time'
|
||||
projectSortBy: 'createdAt' | 'updatedAt'
|
||||
}
|
||||
|
||||
export type DesktopUiPreferences = {
|
||||
@ -30,6 +32,8 @@ const DEFAULT_SIDEBAR_PROJECT_PREFERENCES: SidebarProjectPreferences = {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: [],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
}
|
||||
|
||||
function defaultPreferences(): DesktopUiPreferences {
|
||||
@ -64,9 +68,19 @@ export function normalizeSidebarProjectPreferences(value: unknown): SidebarProje
|
||||
projectOrder: normalizeStringArray(record.projectOrder),
|
||||
pinnedProjects: normalizeStringArray(record.pinnedProjects),
|
||||
hiddenProjects: normalizeStringArray(record.hiddenProjects),
|
||||
projectOrganization: normalizeProjectOrganization(record.projectOrganization),
|
||||
projectSortBy: normalizeProjectSortBy(record.projectSortBy),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProjectOrganization(value: unknown): SidebarProjectPreferences['projectOrganization'] {
|
||||
return value === 'project' || value === 'recentProject' || value === 'time' ? value : 'recentProject'
|
||||
}
|
||||
|
||||
function normalizeProjectSortBy(value: unknown): SidebarProjectPreferences['projectSortBy'] {
|
||||
return value === 'createdAt' || value === 'updatedAt' ? value : 'updatedAt'
|
||||
}
|
||||
|
||||
function normalizeDesktopUiPreferences(value: unknown): DesktopUiPreferences | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user