mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
179 lines
5.5 KiB
TypeScript
179 lines
5.5 KiB
TypeScript
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
|
|
})
|
|
}
|
|
}
|