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:
程序员阿江(Relakkes)
2026-05-15 16:30:39 +08:00
parent 0e78439f9d
commit 5d9d1b009e
9 changed files with 766 additions and 34 deletions
+30
View 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,
)
},
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -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',
+3 -4
View File
@@ -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': '收起会话',
@@ -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
View 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')
}
+4
View File
@@ -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)
@@ -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
})
}
}