From 5d9d1b009ed3fe8db93bbf701e4ccc3c26e67d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 15 May 2026 16:30:39 +0800 Subject: [PATCH] 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 --- desktop/src/api/desktopUiPreferences.ts | 30 +++ .../src/components/layout/Sidebar.test.tsx | 168 ++++++++++++++++- desktop/src/components/layout/Sidebar.tsx | 178 ++++++++++++++++-- desktop/src/i18n/locales/en.ts | 7 +- desktop/src/i18n/locales/zh.ts | 7 +- .../__tests__/desktop-ui-preferences.test.ts | 170 +++++++++++++++++ src/server/api/desktop-ui.ts | 58 ++++++ src/server/router.ts | 4 + .../services/desktopUiPreferencesService.ts | 178 ++++++++++++++++++ 9 files changed, 766 insertions(+), 34 deletions(-) create mode 100644 desktop/src/api/desktopUiPreferences.ts create mode 100644 src/server/__tests__/desktop-ui-preferences.test.ts create mode 100644 src/server/api/desktop-ui.ts create mode 100644 src/server/services/desktopUiPreferencesService.ts diff --git a/desktop/src/api/desktopUiPreferences.ts b/desktop/src/api/desktopUiPreferences.ts new file mode 100644 index 00000000..2e2ebc76 --- /dev/null +++ b/desktop/src/api/desktopUiPreferences.ts @@ -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('/api/desktop-ui/preferences') + }, + + updateSidebarPreferences(sidebar: SidebarProjectPreferences) { + return api.put<{ ok: true; preferences: DesktopUiPreferences }>( + '/api/desktop-ui/preferences/sidebar', + sidebar, + ) + }, +} diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 919d6357..5926b8a6 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -6,6 +6,15 @@ vi.mock('./ProjectFilter', () => ({ ProjectFilter: () =>
, })) +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() + + 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() + + 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() + + 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() + + 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() + + 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({ diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 2f4e8903..a3b60a05 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -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>(new Set()) const [projectOrder, setProjectOrder] = useState(() => readStoredProjectOrder()) const [pinnedProjectKeys, setPinnedProjectKeys] = useState>(() => readStoredProjectPins()) + const [hiddenProjectKeys, setHiddenProjectKeys] = useState>(() => readStoredProjectHidden()) const [draggingProjectKey, setDraggingProjectKey] = useState(null) const [projectDropTarget, setProjectDropTarget] = useState<{ key: string; position: 'before' | 'after' } | null>(null) const suppressProjectClickRef = useRef(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')}
)} - {orderedProjectGroups.length > 0 && ( + {visibleProjectGroups.length > 0 && (
{t('sidebar.projects')}
)} - {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 (
{t('sidebar.openInFinder')} - - - -
) @@ -1127,6 +1195,80 @@ function writeStoredProjectPins(projectKeys: Set): void { } } +function readStoredProjectHidden(): Set { + 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): 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, + hiddenProjectKeys: Set, +): 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 | 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() + 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, diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index c301f3e6..102dbcdd 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 259f7a22..2135c3b4 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -35,10 +35,9 @@ export const zh: Record = { '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': '收起会话', diff --git a/src/server/__tests__/desktop-ui-preferences.test.ts b/src/server/__tests__/desktop-ui-preferences.test.ts new file mode 100644 index 00000000..92c4c1bf --- /dev/null +++ b/src/server/__tests__/desktop-ui-preferences.test.ts @@ -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, +): { 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> { + const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'desktop-ui.json'), 'utf-8') + return JSON.parse(raw) as Record +} + +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 + + 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 + + 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'], + }, + }, + }) + }) +}) diff --git a/src/server/api/desktop-ui.ts b/src/server/api/desktop-ui.ts new file mode 100644 index 00000000..0d90573f --- /dev/null +++ b/src/server/api/desktop-ui.ts @@ -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 { + 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> { + try { + return (await req.json()) as Record + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} + +function methodNotAllowed(method: string): ApiError { + return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED') +} diff --git a/src/server/router.ts b/src/server/router.ts index 36f81929..7d0473e4 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -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 { const path = url.pathname @@ -115,6 +116,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise() + 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 + 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 + 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>() + + 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 { + 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( + filePath: string, + task: () => Promise, + ): Promise { + 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 { + 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 { + 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 { + 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 + }) + } +}