mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Persist sidebar project preferences across desktop and H5
Sidebar project ordering, pinning, and hidden-project state must survive browser access to the same local server, so the UI now stores these preferences under the cc-haha config directory and keeps localStorage as a migration/cache fallback. Constraint: Browser and H5 localStorage is isolated from the Tauri WebView Rejected: Keep sidebar project preferences only in localStorage | browser sessions would not share state Rejected: Reuse cc-haha/settings.json | provider and H5 access settings should stay separate Confidence: high Scope-risk: moderate Directive: Keep sidebar hide/remove semantics non-destructive; do not delete transcript files for project removal Tested: cd desktop && bun run test -- src/components/layout/Sidebar.test.tsx --run Tested: cd desktop && bun run lint Tested: bun test src/server/__tests__/desktop-ui-preferences.test.ts Tested: bun run check:persistence-upgrade Tested: bun run check:server
This commit is contained in:
parent
0e78439f9d
commit
5d9d1b009e
30
desktop/src/api/desktopUiPreferences.ts
Normal file
30
desktop/src/api/desktopUiPreferences.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { api } from './client'
|
||||
|
||||
export type SidebarProjectPreferences = {
|
||||
projectOrder: string[]
|
||||
pinnedProjects: string[]
|
||||
hiddenProjects: string[]
|
||||
}
|
||||
|
||||
export type DesktopUiPreferences = {
|
||||
schemaVersion: number
|
||||
sidebar: SidebarProjectPreferences
|
||||
}
|
||||
|
||||
export type DesktopUiPreferencesResponse = {
|
||||
preferences: DesktopUiPreferences
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
export const desktopUiPreferencesApi = {
|
||||
getPreferences() {
|
||||
return api.get<DesktopUiPreferencesResponse>('/api/desktop-ui/preferences')
|
||||
},
|
||||
|
||||
updateSidebarPreferences(sidebar: SidebarProjectPreferences) {
|
||||
return api.put<{ ok: true; preferences: DesktopUiPreferences }>(
|
||||
'/api/desktop-ui/preferences/sidebar',
|
||||
sidebar,
|
||||
)
|
||||
},
|
||||
}
|
||||
@ -6,6 +6,15 @@ vi.mock('./ProjectFilter', () => ({
|
||||
ProjectFilter: () => <div data-testid="project-filter" />,
|
||||
}))
|
||||
|
||||
const desktopUiPreferencesApiMock = vi.hoisted(() => ({
|
||||
getPreferences: vi.fn(),
|
||||
updateSidebarPreferences: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/desktopUiPreferences', () => ({
|
||||
desktopUiPreferencesApi: desktopUiPreferencesApiMock,
|
||||
}))
|
||||
|
||||
const openTargetStoreMock = vi.hoisted(() => ({
|
||||
ensureTargets: vi.fn(),
|
||||
openTarget: vi.fn(),
|
||||
@ -36,10 +45,9 @@ vi.mock('../../i18n', () => ({
|
||||
'sidebar.openInFinder': 'Open in Finder',
|
||||
'sidebar.openInFinderFailed': 'Could not open the project in Finder.',
|
||||
'sidebar.openInFinderUnavailable': 'No file manager is available.',
|
||||
'sidebar.createPermanentWorktree': 'Create Permanent Worktree',
|
||||
'sidebar.renameProject': 'Rename Project',
|
||||
'sidebar.archiveSessions': 'Archive Conversations',
|
||||
'sidebar.removeProject': 'Remove',
|
||||
'sidebar.hideProjectFromSidebar': 'Remove from Sidebar',
|
||||
'sidebar.restoreProjectToSidebar': 'Restore to Sidebar',
|
||||
'sidebar.projectHidden': '{project} was removed from the sidebar.',
|
||||
'sidebar.newSessionInProject': 'New session in {project}',
|
||||
'sidebar.showMoreSessions': 'Show {count} more',
|
||||
'sidebar.showFewerSessions': 'Show fewer',
|
||||
@ -91,6 +99,7 @@ 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'
|
||||
|
||||
function makeSession(
|
||||
id: string,
|
||||
@ -150,11 +159,26 @@ describe('Sidebar', () => {
|
||||
deleteSession.mockReset()
|
||||
deleteSessions.mockReset()
|
||||
addToast.mockReset()
|
||||
desktopUiPreferencesApiMock.getPreferences.mockReset()
|
||||
desktopUiPreferencesApiMock.updateSidebarPreferences.mockReset()
|
||||
desktopUiPreferencesApiMock.getPreferences.mockRejectedValue(new Error('server unavailable'))
|
||||
desktopUiPreferencesApiMock.updateSidebarPreferences.mockResolvedValue({
|
||||
ok: true,
|
||||
preferences: {
|
||||
schemaVersion: 1,
|
||||
sidebar: {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
openTargetStoreMock.ensureTargets.mockReset()
|
||||
openTargetStoreMock.openTarget.mockReset()
|
||||
openTargetStoreMock.targets = [{ id: 'finder', kind: 'file_manager', label: 'Finder', platform: 'darwin' }]
|
||||
window.localStorage.removeItem(PROJECT_ORDER_STORAGE_KEY)
|
||||
window.localStorage.removeItem(PROJECT_PINNED_STORAGE_KEY)
|
||||
window.localStorage.removeItem(PROJECT_HIDDEN_STORAGE_KEY)
|
||||
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useSessionStore.setState({
|
||||
@ -187,6 +211,7 @@ describe('Sidebar', () => {
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
window.localStorage.removeItem(PROJECT_ORDER_STORAGE_KEY)
|
||||
window.localStorage.removeItem(PROJECT_PINNED_STORAGE_KEY)
|
||||
window.localStorage.removeItem(PROJECT_HIDDEN_STORAGE_KEY)
|
||||
})
|
||||
|
||||
it('opens a new tab when creating a session from the sidebar', async () => {
|
||||
@ -396,10 +421,10 @@ describe('Sidebar', () => {
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'Pin Project' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Open in Finder' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Create Permanent Worktree' })).toBeDisabled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Rename Project' })).toBeDisabled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Archive Conversations' })).toBeDisabled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Remove' })).toBeDisabled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Remove from Sidebar' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('menuitem', { name: 'Create Permanent Worktree' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('menuitem', { name: 'Rename Project' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('menuitem', { name: 'Archive Conversations' })).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Open in Finder' }))
|
||||
@ -431,6 +456,133 @@ describe('Sidebar', () => {
|
||||
expect(JSON.parse(window.localStorage.getItem(PROJECT_PINNED_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/beta'])
|
||||
})
|
||||
|
||||
it('removes a project from the sidebar without deleting its sessions', async () => {
|
||||
const base = new Date('2026-05-15T10:00:00.000Z').getTime()
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date(base).toISOString()),
|
||||
makeSession('beta-1', 'Beta Session', '/workspace/beta', new Date(base - 20_000).toISOString()),
|
||||
],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project actions for beta' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Remove from Sidebar' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('beta')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument()
|
||||
expect(deleteSessions).not.toHaveBeenCalled()
|
||||
expect(deleteSession).not.toHaveBeenCalled()
|
||||
expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/beta'])
|
||||
expect(addToast).toHaveBeenCalledWith({
|
||||
type: 'info',
|
||||
message: 'beta was removed from the sidebar.',
|
||||
})
|
||||
})
|
||||
|
||||
it('restores hidden project state from localStorage and shows selected hidden projects for recovery', () => {
|
||||
window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['/workspace/beta']))
|
||||
const now = new Date().toISOString()
|
||||
useSessionStore.setState({
|
||||
selectedProjects: ['/workspace/beta'],
|
||||
sessions: [
|
||||
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now),
|
||||
makeSession('beta-1', 'Beta Session', '/workspace/beta', now),
|
||||
],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(screen.queryByText('alpha')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('beta')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project actions for beta' }))
|
||||
expect(screen.getByRole('menuitem', { name: 'Restore to Sidebar' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses server sidebar preferences across browser and desktop storage contexts', async () => {
|
||||
desktopUiPreferencesApiMock.getPreferences.mockResolvedValueOnce({
|
||||
exists: true,
|
||||
preferences: {
|
||||
schemaVersion: 1,
|
||||
sidebar: {
|
||||
projectOrder: ['/workspace/beta', '/workspace/alpha'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: ['/workspace/alpha'],
|
||||
},
|
||||
},
|
||||
})
|
||||
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 />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('alpha')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('beta')).toBeInTheDocument()
|
||||
})
|
||||
expect(JSON.parse(window.localStorage.getItem(PROJECT_ORDER_STORAGE_KEY) ?? '[]')).toEqual([
|
||||
'/workspace/beta',
|
||||
'/workspace/alpha',
|
||||
])
|
||||
expect(JSON.parse(window.localStorage.getItem(PROJECT_PINNED_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/beta'])
|
||||
expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/alpha'])
|
||||
})
|
||||
|
||||
it('migrates cached local sidebar preferences when the server file is missing after update', async () => {
|
||||
desktopUiPreferencesApiMock.getPreferences.mockResolvedValueOnce({
|
||||
exists: false,
|
||||
preferences: {
|
||||
schemaVersion: 1,
|
||||
sidebar: {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['/workspace/beta']))
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString()),
|
||||
makeSession('beta-1', 'Beta Session', '/workspace/beta', new Date().toISOString()),
|
||||
],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: ['/workspace/beta'],
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText('beta')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores corrupt hidden project storage for backward compatibility', () => {
|
||||
window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, '{bad json')
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString()),
|
||||
],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Alpha Session/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps persisted worktree sessions under the source project group', () => {
|
||||
const now = new Date().toISOString()
|
||||
useSessionStore.setState({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
import { Archive, ChevronDown, Folder, FolderOpen, GitBranch, MoreHorizontal, Pencil, Pin, PinOff, RefreshCw, SquarePen, X } from 'lucide-react'
|
||||
import { ChevronDown, Folder, FolderOpen, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@ -9,6 +9,7 @@ import type { SessionListItem } from '../../types/session'
|
||||
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
||||
import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences'
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
||||
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
|
||||
@ -16,6 +17,7 @@ const SESSION_LIST_AUTO_REFRESH_MS = 30_000
|
||||
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_GROUP_VISIBLE_COUNT = 6
|
||||
const PROJECT_GROUP_SCROLL_COUNT = 12
|
||||
|
||||
@ -68,9 +70,11 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
||||
const [projectOrder, setProjectOrder] = useState<string[]>(() => readStoredProjectOrder())
|
||||
const [pinnedProjectKeys, setPinnedProjectKeys] = useState<Set<string>>(() => readStoredProjectPins())
|
||||
const [hiddenProjectKeys, setHiddenProjectKeys] = useState<Set<string>>(() => readStoredProjectHidden())
|
||||
const [draggingProjectKey, setDraggingProjectKey] = useState<string | null>(null)
|
||||
const [projectDropTarget, setProjectDropTarget] = useState<{ key: string; position: 'before' | 'after' } | null>(null)
|
||||
const suppressProjectClickRef = useRef<string | null>(null)
|
||||
const sidebarPreferenceRevisionRef = useRef(0)
|
||||
const refreshSessionsNow = useSessionListAutoRefresh(fetchSessions)
|
||||
|
||||
useEffect(() => {
|
||||
@ -106,6 +110,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
() => applyProjectOrder(projectGroups, projectOrder, pinnedProjectKeys),
|
||||
[projectGroups, projectOrder, pinnedProjectKeys],
|
||||
)
|
||||
const visibleProjectGroups = useMemo(() => {
|
||||
if (hiddenProjectKeys.size === 0) return orderedProjectGroups
|
||||
return orderedProjectGroups.filter((project) => (
|
||||
!hiddenProjectKeys.has(project.key) || selectedProjects.includes(project.key)
|
||||
))
|
||||
}, [hiddenProjectKeys, orderedProjectGroups, selectedProjects])
|
||||
const showInitialLoading = isLoading && sessions.length === 0
|
||||
const filteredSessionIds = useMemo(() => filteredSessions.map((session) => session.id), [filteredSessions])
|
||||
const selectedCount = selectedSessionIds.size
|
||||
@ -124,6 +134,47 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
if (isMobile) onRequestClose?.()
|
||||
}, [isMobile, onRequestClose])
|
||||
|
||||
const applySidebarProjectPreferences = useCallback((preferences: SidebarProjectPreferences) => {
|
||||
setProjectOrder(preferences.projectOrder)
|
||||
setPinnedProjectKeys(new Set(preferences.pinnedProjects))
|
||||
setHiddenProjectKeys(new Set(preferences.hiddenProjects))
|
||||
}, [])
|
||||
|
||||
const persistSidebarProjectPreferences = useCallback((preferences: SidebarProjectPreferences) => {
|
||||
const normalized = normalizeSidebarProjectPreferences(preferences)
|
||||
sidebarPreferenceRevisionRef.current += 1
|
||||
writeCachedSidebarProjectPreferences(normalized)
|
||||
void desktopUiPreferencesApi.updateSidebarPreferences(normalized).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const startRevision = sidebarPreferenceRevisionRef.current
|
||||
|
||||
void desktopUiPreferencesApi.getPreferences()
|
||||
.then((response) => {
|
||||
if (cancelled || startRevision !== sidebarPreferenceRevisionRef.current) return
|
||||
|
||||
const localPreferences = readCachedSidebarProjectPreferences()
|
||||
const serverPreferences = normalizeSidebarProjectPreferences(response.preferences.sidebar)
|
||||
const effectivePreferences = response.exists ? serverPreferences : localPreferences
|
||||
|
||||
applySidebarProjectPreferences(effectivePreferences)
|
||||
writeCachedSidebarProjectPreferences(effectivePreferences)
|
||||
|
||||
if (!response.exists && hasSidebarProjectPreferences(localPreferences)) {
|
||||
void desktopUiPreferencesApi.updateSidebarPreferences(localPreferences).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// The sidebar remains usable with the local cache if the server is still booting.
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [applySidebarProjectPreferences])
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => {
|
||||
e.preventDefault()
|
||||
if (isBatchMode) return
|
||||
@ -180,9 +231,9 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
dropPosition,
|
||||
)
|
||||
setProjectOrder(nextOrder)
|
||||
writeStoredProjectOrder(nextOrder)
|
||||
persistSidebarProjectPreferences(buildSidebarProjectPreferences(nextOrder, pinnedProjectKeys, hiddenProjectKeys))
|
||||
clearProjectDragState()
|
||||
}, [clearProjectDragState, draggingProjectKey, orderedProjectGroups, projectDropTarget])
|
||||
}, [clearProjectDragState, draggingProjectKey, hiddenProjectKeys, orderedProjectGroups, persistSidebarProjectPreferences, pinnedProjectKeys, projectDropTarget])
|
||||
|
||||
const createSessionForWorkDir = useCallback(async (workDir?: string) => {
|
||||
try {
|
||||
@ -207,10 +258,31 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
} else {
|
||||
next.add(projectKey)
|
||||
}
|
||||
writeStoredProjectPins(next)
|
||||
persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, next, hiddenProjectKeys))
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
}, [hiddenProjectKeys, persistSidebarProjectPreferences, projectOrder])
|
||||
|
||||
const toggleHiddenProject = useCallback((project: ProjectGroup) => {
|
||||
const wasHidden = hiddenProjectKeys.has(project.key)
|
||||
setProjectContextMenu(null)
|
||||
setHiddenProjectKeys((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(project.key)) {
|
||||
next.delete(project.key)
|
||||
} else {
|
||||
next.add(project.key)
|
||||
}
|
||||
persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, pinnedProjectKeys, next))
|
||||
return next
|
||||
})
|
||||
if (!wasHidden) {
|
||||
addToast({
|
||||
type: 'info',
|
||||
message: t('sidebar.projectHidden', { project: project.title }),
|
||||
})
|
||||
}
|
||||
}, [addToast, hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, t])
|
||||
|
||||
const openProjectInFinder = useCallback(async (project: ProjectGroup) => {
|
||||
setProjectContextMenu(null)
|
||||
@ -611,12 +683,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
{searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')}
|
||||
</div>
|
||||
)}
|
||||
{orderedProjectGroups.length > 0 && (
|
||||
{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>
|
||||
)}
|
||||
{orderedProjectGroups.map((project) => {
|
||||
{visibleProjectGroups.map((project) => {
|
||||
const projectCollapsed = collapsedProjectKeys.has(project.key)
|
||||
const sessionsExpanded = expandedProjectKeys.has(project.key)
|
||||
const visibleItems = projectCollapsed
|
||||
@ -884,6 +956,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
const project = orderedProjectGroups.find((group) => group.key === projectContextMenu.key)
|
||||
if (!project) return null
|
||||
const pinned = pinnedProjectKeys.has(project.key)
|
||||
const hidden = hiddenProjectKeys.has(project.key)
|
||||
return (
|
||||
<div
|
||||
role="menu"
|
||||
@ -903,17 +976,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
>
|
||||
{t('sidebar.openInFinder')}
|
||||
</ProjectMenuItem>
|
||||
<ProjectMenuItem icon={<GitBranch size={18} aria-hidden="true" />} disabled>
|
||||
{t('sidebar.createPermanentWorktree')}
|
||||
</ProjectMenuItem>
|
||||
<ProjectMenuItem icon={<Pencil size={18} aria-hidden="true" />} disabled>
|
||||
{t('sidebar.renameProject')}
|
||||
</ProjectMenuItem>
|
||||
<ProjectMenuItem icon={<Archive size={18} aria-hidden="true" />} disabled>
|
||||
{t('sidebar.archiveSessions')}
|
||||
</ProjectMenuItem>
|
||||
<ProjectMenuItem icon={<X size={18} aria-hidden="true" />} disabled danger>
|
||||
{t('sidebar.removeProject')}
|
||||
<ProjectMenuItem
|
||||
icon={hidden ? <RotateCcw size={18} aria-hidden="true" /> : <X size={18} aria-hidden="true" />}
|
||||
onClick={() => toggleHiddenProject(project)}
|
||||
danger={!hidden}
|
||||
>
|
||||
{t(hidden ? 'sidebar.restoreProjectToSidebar' : 'sidebar.hideProjectFromSidebar')}
|
||||
</ProjectMenuItem>
|
||||
</div>
|
||||
)
|
||||
@ -1127,6 +1195,80 @@ function writeStoredProjectPins(projectKeys: Set<string>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredProjectHidden(): Set<string> {
|
||||
if (typeof localStorage === 'undefined') return new Set()
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [])
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredProjectHidden(projectKeys: Set<string>): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify([...projectKeys]))
|
||||
} catch {
|
||||
// Hidden projects are a local UI preference; ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
function buildSidebarProjectPreferences(
|
||||
projectOrder: string[],
|
||||
pinnedProjectKeys: Set<string>,
|
||||
hiddenProjectKeys: Set<string>,
|
||||
): SidebarProjectPreferences {
|
||||
return normalizeSidebarProjectPreferences({
|
||||
projectOrder,
|
||||
pinnedProjects: [...pinnedProjectKeys],
|
||||
hiddenProjects: [...hiddenProjectKeys],
|
||||
})
|
||||
}
|
||||
|
||||
function readCachedSidebarProjectPreferences(): SidebarProjectPreferences {
|
||||
return {
|
||||
projectOrder: readStoredProjectOrder(),
|
||||
pinnedProjects: [...readStoredProjectPins()],
|
||||
hiddenProjects: [...readStoredProjectHidden()],
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedSidebarProjectPreferences(preferences: SidebarProjectPreferences): void {
|
||||
const normalized = normalizeSidebarProjectPreferences(preferences)
|
||||
writeStoredProjectOrder(normalized.projectOrder)
|
||||
writeStoredProjectPins(new Set(normalized.pinnedProjects))
|
||||
writeStoredProjectHidden(new Set(normalized.hiddenProjects))
|
||||
}
|
||||
|
||||
function normalizeSidebarProjectPreferences(preferences: Partial<SidebarProjectPreferences> | undefined): SidebarProjectPreferences {
|
||||
return {
|
||||
projectOrder: normalizeProjectKeyList(preferences?.projectOrder),
|
||||
pinnedProjects: normalizeProjectKeyList(preferences?.pinnedProjects),
|
||||
hiddenProjects: normalizeProjectKeyList(preferences?.hiddenProjects),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProjectKeyList(values: unknown): string[] {
|
||||
if (!Array.isArray(values)) return []
|
||||
const seen = new Set<string>()
|
||||
const normalized: string[] = []
|
||||
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string' || value.length === 0 || seen.has(value)) continue
|
||||
seen.add(value)
|
||||
normalized.push(value)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function hasSidebarProjectPreferences(preferences: SidebarProjectPreferences): boolean {
|
||||
return preferences.projectOrder.length > 0
|
||||
|| preferences.pinnedProjects.length > 0
|
||||
|| preferences.hiddenProjects.length > 0
|
||||
}
|
||||
|
||||
function getVisibleProjectSessions(
|
||||
sessions: SessionListItem[],
|
||||
expanded: boolean,
|
||||
|
||||
@ -33,10 +33,9 @@ export const en = {
|
||||
'sidebar.openInFinder': 'Open in Finder',
|
||||
'sidebar.openInFinderFailed': 'Could not open the project in Finder.',
|
||||
'sidebar.openInFinderUnavailable': 'No file manager is available.',
|
||||
'sidebar.createPermanentWorktree': 'Create Permanent Worktree',
|
||||
'sidebar.renameProject': 'Rename Project',
|
||||
'sidebar.archiveSessions': 'Archive Conversations',
|
||||
'sidebar.removeProject': 'Remove',
|
||||
'sidebar.hideProjectFromSidebar': 'Remove from Sidebar',
|
||||
'sidebar.restoreProjectToSidebar': 'Restore to Sidebar',
|
||||
'sidebar.projectHidden': '{project} was removed from the sidebar.',
|
||||
'sidebar.newSessionInProject': 'New session in {project}',
|
||||
'sidebar.showMoreSessions': 'Show {count} more',
|
||||
'sidebar.showFewerSessions': 'Show fewer',
|
||||
|
||||
@ -35,10 +35,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'sidebar.openInFinder': '在“访达”中打开',
|
||||
'sidebar.openInFinderFailed': '无法在访达中打开项目。',
|
||||
'sidebar.openInFinderUnavailable': '没有可用的文件管理器。',
|
||||
'sidebar.createPermanentWorktree': '创建永久工作树',
|
||||
'sidebar.renameProject': '重命名项目',
|
||||
'sidebar.archiveSessions': '归档对话',
|
||||
'sidebar.removeProject': '移除',
|
||||
'sidebar.hideProjectFromSidebar': '从侧边栏移除',
|
||||
'sidebar.restoreProjectToSidebar': '恢复到侧边栏',
|
||||
'sidebar.projectHidden': '已从侧边栏移除 {project}。',
|
||||
'sidebar.newSessionInProject': '在 {project} 中新建会话',
|
||||
'sidebar.showMoreSessions': '查看更多 {count} 个',
|
||||
'sidebar.showFewerSessions': '收起会话',
|
||||
|
||||
170
src/server/__tests__/desktop-ui-preferences.test.ts
Normal file
170
src/server/__tests__/desktop-ui-preferences.test.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { handleDesktopUiApi } from '../api/desktop-ui.js'
|
||||
import { DesktopUiPreferencesService } from '../services/desktopUiPreferencesService.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
async function setup() {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'desktop-ui-preferences-'))
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
if (originalConfigDir !== undefined) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function makeRequest(
|
||||
method: string,
|
||||
urlStr: string,
|
||||
body?: Record<string, unknown>,
|
||||
): { req: Request; url: URL; segments: string[] } {
|
||||
const url = new URL(urlStr, 'http://localhost:3456')
|
||||
const init: RequestInit = { method }
|
||||
if (body !== undefined) {
|
||||
init.headers = { 'Content-Type': 'application/json' }
|
||||
init.body = JSON.stringify(body)
|
||||
}
|
||||
const req = new Request(url.toString(), init)
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
return { req, url, segments }
|
||||
}
|
||||
|
||||
async function readDesktopUiFile(): Promise<Record<string, unknown>> {
|
||||
const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'desktop-ui.json'), 'utf-8')
|
||||
return JSON.parse(raw) as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('DesktopUiPreferencesService', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
|
||||
test('returns defaults when desktop-ui.json does not exist', async () => {
|
||||
const service = new DesktopUiPreferencesService()
|
||||
|
||||
const result = await service.readPreferences()
|
||||
|
||||
expect(result.exists).toBe(false)
|
||||
expect(result.preferences).toEqual({
|
||||
schemaVersion: 1,
|
||||
sidebar: {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizes old schema files and preserves unknown fields when updating sidebar preferences', async () => {
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'desktop-ui.json'),
|
||||
JSON.stringify({
|
||||
futureField: { keep: true },
|
||||
sidebar: {
|
||||
projectOrder: ['/workspace/alpha', 42, '/workspace/alpha', '/workspace/beta'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: [null, '/workspace/gamma'],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const service = new DesktopUiPreferencesService()
|
||||
const before = await service.readPreferences()
|
||||
const after = await service.updateSidebarPreferences({
|
||||
projectOrder: ['/workspace/gamma'],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: ['/workspace/beta'],
|
||||
})
|
||||
|
||||
expect(before.exists).toBe(true)
|
||||
expect(before.preferences).toEqual({
|
||||
schemaVersion: 1,
|
||||
futureField: { keep: true },
|
||||
sidebar: {
|
||||
projectOrder: ['/workspace/alpha', '/workspace/beta'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: ['/workspace/gamma'],
|
||||
},
|
||||
})
|
||||
expect(after).toEqual({
|
||||
schemaVersion: 1,
|
||||
futureField: { keep: true },
|
||||
sidebar: {
|
||||
projectOrder: ['/workspace/gamma'],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: ['/workspace/beta'],
|
||||
},
|
||||
})
|
||||
expect(await readDesktopUiFile()).toEqual(after)
|
||||
})
|
||||
|
||||
test('quarantines corrupt desktop-ui.json and reports defaults as missing', async () => {
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(path.join(tmpDir, 'cc-haha', 'desktop-ui.json'), '{bad json', 'utf-8')
|
||||
|
||||
const service = new DesktopUiPreferencesService()
|
||||
const result = await service.readPreferences()
|
||||
const files = await fs.readdir(path.join(tmpDir, 'cc-haha'))
|
||||
|
||||
expect(result.exists).toBe(false)
|
||||
expect(result.preferences.sidebar.hiddenProjects).toEqual([])
|
||||
expect(files.some((name) => name.startsWith('desktop-ui.json.invalid-'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktop UI preferences API', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
|
||||
test('persists sidebar preferences under cc-haha desktop-ui.json', async () => {
|
||||
const putReq = makeRequest('PUT', '/api/desktop-ui/preferences/sidebar', {
|
||||
projectOrder: ['/workspace/beta', '/workspace/alpha'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: ['/workspace/old'],
|
||||
})
|
||||
|
||||
const putRes = await handleDesktopUiApi(putReq.req, putReq.url, putReq.segments)
|
||||
const putBody = await putRes.json() as Record<string, unknown>
|
||||
|
||||
expect(putRes.status).toBe(200)
|
||||
expect(putBody).toEqual({
|
||||
ok: true,
|
||||
preferences: {
|
||||
schemaVersion: 1,
|
||||
sidebar: {
|
||||
projectOrder: ['/workspace/beta', '/workspace/alpha'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: ['/workspace/old'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getReq = makeRequest('GET', '/api/desktop-ui/preferences')
|
||||
const getRes = await handleDesktopUiApi(getReq.req, getReq.url, getReq.segments)
|
||||
const getBody = await getRes.json() as Record<string, unknown>
|
||||
|
||||
expect(getRes.status).toBe(200)
|
||||
expect(getBody).toEqual({
|
||||
exists: true,
|
||||
preferences: {
|
||||
schemaVersion: 1,
|
||||
sidebar: {
|
||||
projectOrder: ['/workspace/beta', '/workspace/alpha'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: ['/workspace/old'],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
58
src/server/api/desktop-ui.ts
Normal file
58
src/server/api/desktop-ui.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Desktop UI Preferences REST API
|
||||
*
|
||||
* GET /api/desktop-ui/preferences — read cc-haha UI preferences
|
||||
* PUT /api/desktop-ui/preferences/sidebar — persist sidebar project preferences
|
||||
*/
|
||||
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import { DesktopUiPreferencesService } from '../services/desktopUiPreferencesService.js'
|
||||
|
||||
const desktopUiPreferencesService = new DesktopUiPreferencesService()
|
||||
|
||||
export async function handleDesktopUiApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
void url
|
||||
|
||||
try {
|
||||
const sub = segments[2]
|
||||
const detail = segments[3]
|
||||
|
||||
if (sub !== 'preferences') {
|
||||
throw ApiError.notFound(`Unknown desktop UI endpoint: ${sub}`)
|
||||
}
|
||||
|
||||
if (detail === undefined) {
|
||||
if (req.method !== 'GET') throw methodNotAllowed(req.method)
|
||||
return Response.json(await desktopUiPreferencesService.readPreferences())
|
||||
}
|
||||
|
||||
if (detail === 'sidebar') {
|
||||
if (req.method !== 'PUT') throw methodNotAllowed(req.method)
|
||||
const body = await parseJsonBody(req)
|
||||
return Response.json({
|
||||
ok: true,
|
||||
preferences: await desktopUiPreferencesService.updateSidebarPreferences(body),
|
||||
})
|
||||
}
|
||||
|
||||
throw ApiError.notFound(`Unknown desktop UI preferences endpoint: ${detail}`)
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return (await req.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
}
|
||||
|
||||
function methodNotAllowed(method: string): ApiError {
|
||||
return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED')
|
||||
}
|
||||
@ -26,6 +26,7 @@ import { handleH5AccessApi } from './api/h5-access.js'
|
||||
import { handleActivityStatsApi } from './api/activityStats.js'
|
||||
import { handleOpenTargetsApi } from './api/open-targets.js'
|
||||
import { handleMemoryApi } from './api/memory.js'
|
||||
import { handleDesktopUiApi } from './api/desktop-ui.js'
|
||||
|
||||
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
||||
const path = url.pathname
|
||||
@ -115,6 +116,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'memory':
|
||||
return handleMemoryApi(req, url, segments)
|
||||
|
||||
case 'desktop-ui':
|
||||
return handleDesktopUiApi(req, url, segments)
|
||||
|
||||
case 'filesystem':
|
||||
return handleFilesystemRoute(url.pathname, url)
|
||||
|
||||
|
||||
178
src/server/services/desktopUiPreferencesService.ts
Normal file
178
src/server/services/desktopUiPreferencesService.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import { readRecoverableJsonFile } from './recoverableJsonFile.js'
|
||||
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
|
||||
|
||||
const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 1
|
||||
const MAX_PROJECT_PREFERENCE_ENTRIES = 2_000
|
||||
|
||||
export type SidebarProjectPreferences = {
|
||||
projectOrder: string[]
|
||||
pinnedProjects: string[]
|
||||
hiddenProjects: string[]
|
||||
}
|
||||
|
||||
export type DesktopUiPreferences = {
|
||||
schemaVersion: number
|
||||
sidebar: SidebarProjectPreferences
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type DesktopUiPreferencesReadResult = {
|
||||
preferences: DesktopUiPreferences
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_SIDEBAR_PROJECT_PREFERENCES: SidebarProjectPreferences = {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: [],
|
||||
}
|
||||
|
||||
function defaultPreferences(): DesktopUiPreferences {
|
||||
return {
|
||||
schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION,
|
||||
sidebar: { ...DEFAULT_SIDEBAR_PROJECT_PREFERENCES },
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const seen = new Set<string>()
|
||||
const normalized: string[] = []
|
||||
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string' || item.length === 0 || seen.has(item)) continue
|
||||
seen.add(item)
|
||||
normalized.push(item)
|
||||
if (normalized.length >= MAX_PROJECT_PREFERENCE_ENTRIES) break
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeSidebarProjectPreferences(value: unknown): SidebarProjectPreferences {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { ...DEFAULT_SIDEBAR_PROJECT_PREFERENCES }
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
return {
|
||||
projectOrder: normalizeStringArray(record.projectOrder),
|
||||
pinnedProjects: normalizeStringArray(record.pinnedProjects),
|
||||
hiddenProjects: normalizeStringArray(record.hiddenProjects),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDesktopUiPreferences(value: unknown): DesktopUiPreferences | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
return {
|
||||
...record,
|
||||
schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION,
|
||||
sidebar: normalizeSidebarProjectPreferences(record.sidebar),
|
||||
}
|
||||
}
|
||||
|
||||
function errnoCode(error: unknown): string | undefined {
|
||||
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
||||
? error.code
|
||||
: undefined
|
||||
}
|
||||
|
||||
export class DesktopUiPreferencesService {
|
||||
private static writeLocks = new Map<string, Promise<void>>()
|
||||
|
||||
private getConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
}
|
||||
|
||||
private getPreferencesPath(): string {
|
||||
return path.join(this.getConfigDir(), 'cc-haha', 'desktop-ui.json')
|
||||
}
|
||||
|
||||
private async fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (errnoCode(error) === 'ENOENT') return false
|
||||
throw ApiError.internal(`Failed to access desktop UI preferences: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async withWriteLock<T>(
|
||||
filePath: string,
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previousWrite = DesktopUiPreferencesService.writeLocks.get(filePath) ?? Promise.resolve()
|
||||
const nextWrite = previousWrite.catch(() => {}).then(task)
|
||||
const trackedWrite = nextWrite.then(() => {}, () => {})
|
||||
|
||||
DesktopUiPreferencesService.writeLocks.set(filePath, trackedWrite)
|
||||
|
||||
try {
|
||||
return await nextWrite
|
||||
} finally {
|
||||
if (DesktopUiPreferencesService.writeLocks.get(filePath) === trackedWrite) {
|
||||
DesktopUiPreferencesService.writeLocks.delete(filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async writePreferences(preferences: DesktopUiPreferences): Promise<void> {
|
||||
const filePath = this.getPreferencesPath()
|
||||
const dir = path.dirname(filePath)
|
||||
const contents = JSON.stringify(preferences, null, 2) + '\n'
|
||||
const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomBytes(6).toString('hex')}`
|
||||
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
|
||||
try {
|
||||
await fs.writeFile(tmpFile, contents, 'utf-8')
|
||||
await fs.rename(tmpFile, filePath)
|
||||
} catch (error) {
|
||||
await fs.unlink(tmpFile).catch(() => {})
|
||||
throw ApiError.internal(`Failed to write desktop-ui.json: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async readPreferences(): Promise<DesktopUiPreferencesReadResult> {
|
||||
await ensurePersistentStorageUpgraded()
|
||||
const filePath = this.getPreferencesPath()
|
||||
const existedBeforeRead = await this.fileExists(filePath)
|
||||
const preferences = await readRecoverableJsonFile({
|
||||
filePath,
|
||||
label: 'cc-haha desktop UI preferences',
|
||||
defaultValue: defaultPreferences(),
|
||||
normalize: normalizeDesktopUiPreferences,
|
||||
})
|
||||
const existsAfterRead = await this.fileExists(filePath)
|
||||
|
||||
return {
|
||||
preferences,
|
||||
exists: existedBeforeRead && existsAfterRead,
|
||||
}
|
||||
}
|
||||
|
||||
async updateSidebarPreferences(sidebar: unknown): Promise<DesktopUiPreferences> {
|
||||
const filePath = this.getPreferencesPath()
|
||||
return this.withWriteLock(filePath, async () => {
|
||||
const { preferences } = await this.readPreferences()
|
||||
const nextPreferences: DesktopUiPreferences = {
|
||||
...preferences,
|
||||
schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION,
|
||||
sidebar: normalizeSidebarProjectPreferences(sidebar),
|
||||
}
|
||||
|
||||
await this.writePreferences(nextPreferences)
|
||||
return nextPreferences
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user