mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +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:
@@ -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
@@ -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': '收起会话',
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user