From b2d0d17584a2de6d5ec1690d05ab60064683b0a4 Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 1 Sep 2026 16:39:54 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E5=86=99=E5=85=A5=20globalStorage=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E6=9B=B4=E6=96=B0=E4=B8=8D=E5=86=8D=E4=B8=A2?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/extension.ts | 3 +- vscode/src/settings/store.test.ts | 91 +++++++++++++++++++++++++++++++ vscode/src/settings/store.ts | 84 +++++++++++++++++++++++----- 3 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 vscode/src/settings/store.test.ts diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 33aa3ad..478cafb 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -9,7 +9,7 @@ import { initIdentity } from './auth/identity' import { setProfilesDirOverride } from './cc/profiles' import { logger } from './logging/logger' import { KanbanWebviewPanel } from './panel/KanbanPanel' -import { getSettings } from './settings/store' +import { getSettings, persistSeededProfiles } from './settings/store' import { registerCreateSessionUriHandler } from './uri/createSessionUriHandler' import { webhookCoordinator } from './webhook/coordinator' @@ -33,6 +33,7 @@ class EmptyTreeProvider implements TreeDataProvider { export function activate(context: ExtensionContext): void { // Profiles 目录 override 在 panel 未打开时也要生效(list / launch 共用)。 setProfilesDirOverride(getSettings(context).profilesDirectory || undefined) + void persistSeededProfiles(context) // Start the webhook server immediately so PR callbacks are received even // when the Kanban panel is closed. Disposed automatically via subscriptions. webhookCoordinator.init(context) diff --git a/vscode/src/settings/store.test.ts b/vscode/src/settings/store.test.ts new file mode 100644 index 0000000..95c7374 --- /dev/null +++ b/vscode/src/settings/store.test.ts @@ -0,0 +1,91 @@ +import type { ExtensionContext, Memento, Uri } from 'vscode' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_COMMIT_PROFILE_NAME } from '../cc/commitProfile' +import { getEffectiveCommitProfilePath, getSettings, persistSeededProfiles, saveSettings, SETTINGS_KEY } from './store' + +const tempDirs: string[] = [] + +afterEach(() => { + vi.restoreAllMocks() + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempDir(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'settings-store-')) + tempDirs.push(dir) + return dir +} + +function fakeCtx(dir: string, memento: Record = {}): ExtensionContext { + return { + extensionPath: path.resolve(__dirname, '../..'), + globalStorageUri: { fsPath: dir } as Uri, + globalState: { + get: (key: string): T | undefined => memento[key] as T | undefined, + update: async (key: string, value: unknown) => { + memento[key] = value + }, + keys: () => Object.keys(memento), + } as unknown as Memento, + } as ExtensionContext +} + +describe('saveSettings 落盘', () => { + it('写入 globalStorage 的 settings.json,清空 memento 后仍能读到 commitProfilePath', async () => { + const dir = tempDir() + const ctx = fakeCtx(dir) + await saveSettings(ctx, { commitProfilePath: '/p/deepseek-v4-pro.json' }) + + const file = path.join(dir, 'settings.json') + expect(existsSync(file)).toBe(true) + expect(JSON.parse(readFileSync(file, 'utf8')).commitProfilePath).toBe('/p/deepseek-v4-pro.json') + + const reloaded = fakeCtx(dir, {}) + expect(getSettings(reloaded).commitProfilePath).toBe('/p/deepseek-v4-pro.json') + expect(await getEffectiveCommitProfilePath(reloaded)).toBe('/p/deepseek-v4-pro.json') + }) +}) + +describe('persistSeededProfiles', () => { + it('memento 里有旧设置、磁盘无文件时,把旧设置迁到 settings.json', async () => { + const dir = tempDir() + const ctx = fakeCtx(dir, { + [SETTINGS_KEY]: { commitProfilePath: '/old/from-memento.json', webhookPort: 9000 }, + }) + await persistSeededProfiles(ctx) + + const reloaded = fakeCtx(dir, {}) + expect(getSettings(reloaded).commitProfilePath).toBe('/old/from-memento.json') + expect(getSettings(reloaded).webhookPort).toBe(9000) + }) + + it('key 缺失且本机有 deepseek-v4-pro 时,把 seed 写进 settings.json', async () => { + const dir = tempDir() + const ctx = fakeCtx(dir) + vi.spyOn(await import('../cc/profiles.js'), 'listClaudeProfiles').mockResolvedValue([ + { name: DEFAULT_COMMIT_PROFILE_NAME, path: '/p/deepseek-v4-pro.json' }, + ]) + await persistSeededProfiles(ctx) + + const reloaded = fakeCtx(dir, {}) + expect(getSettings(reloaded).commitProfilePath).toBe('/p/deepseek-v4-pro.json') + }) + + it('用户主动存了空串,不再被 seed 覆盖', async () => { + const dir = tempDir() + const ctx = fakeCtx(dir) + await saveSettings(ctx, { commitProfilePath: '' }) + vi.spyOn(await import('../cc/profiles.js'), 'listClaudeProfiles').mockResolvedValue([ + { name: DEFAULT_COMMIT_PROFILE_NAME, path: '/p/deepseek-v4-pro.json' }, + ]) + await persistSeededProfiles(ctx) + expect(getSettings(ctx).commitProfilePath).toBe('') + }) +}) diff --git a/vscode/src/settings/store.ts b/vscode/src/settings/store.ts index 8a3173a..734468f 100644 --- a/vscode/src/settings/store.ts +++ b/vscode/src/settings/store.ts @@ -1,9 +1,10 @@ /** - * Non-secret extension settings persisted in `globalState`. + * Non-secret extension settings persisted as `settings.json` under + * `context.globalStorageUri`. That directory survives VSIX 更新;`globalState` + * 在部分 VS Code profile 下只活在内存里,一重载就丢。 * - * Token stays in `context.secrets` (see `src/auth/secrets.ts`); everything - * else (webhook port, prompt templates) lives here under a single JSON blob - * keyed by `SETTINGS_KEY`. + * Token stays in `context.secrets` (see `src/auth/secrets.ts`). + * 读:文件 → 旧 globalState(一次性迁移)。写:只写文件。 * * Empty-string values for prompts are treated as "use the default" so the * form can save user input verbatim and still fall back when the user clears @@ -11,7 +12,7 @@ */ import type { ExtensionContext } from 'vscode' -import { readFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import path from 'node:path' import { seedCommitProfilePath } from '../cc/commitProfile' import { listClaudeProfiles } from '../cc/profiles' @@ -208,6 +209,40 @@ export interface Settings { } export const SETTINGS_KEY = 'superpowers.settings' +const SETTINGS_FILE = 'settings.json' + +export function settingsFilePath(ctx: ExtensionContext): string { + return path.join(ctx.globalStorageUri.fsPath, SETTINGS_FILE) +} + +function parseStoredBlob(raw: unknown): Partial | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) + return null + return raw as Partial +} + +/** 文件优先;没有文件时读旧 globalState,供迁移。 */ +export function readStoredSettings(ctx: ExtensionContext): Partial { + try { + const parsed: unknown = JSON.parse(readFileSync(settingsFilePath(ctx), 'utf8')) + const blob = parseStoredBlob(parsed) + if (blob) + return blob + } + catch { + // 文件不存在或坏掉:回退 memento。 + } + return parseStoredBlob(ctx.globalState.get(SETTINGS_KEY)) ?? {} +} + +function writeStoredSettings(ctx: ExtensionContext, blob: Partial): void { + const dir = ctx.globalStorageUri.fsPath + mkdirSync(dir, { recursive: true }) + const file = settingsFilePath(ctx) + const tmp = `${file}.tmp` + writeFileSync(tmp, `${JSON.stringify(blob, null, 2)}\n`) + renameSync(tmp, file) +} function defaults(ctx: ExtensionContext): Settings { return { @@ -242,7 +277,7 @@ function defaults(ctx: ExtensionContext): Settings { } export function getSettings(ctx: ExtensionContext): Settings { - const stored = ctx.globalState.get>(SETTINGS_KEY) ?? {} + const stored = readStoredSettings(ctx) const base = defaults(ctx) const webhookPort = typeof stored.webhookPort === 'number' && Number.isInteger(stored.webhookPort) @@ -384,11 +419,11 @@ export function getSettings(ctx: ExtensionContext): Settings { /** * 读取工具栏「提交代码」有效的 profile path。 - * ① raw stored 已有 key(含用户清空的 '')→ 原样返回 - * ② key 从未出现 → list profiles 后 seed deepseek-v4-pro(不写回磁盘) + * ① stored 已有 key(含用户清空的 '')→ 原样返回 + * ② key 从未出现 → list profiles 后 seed deepseek-v4-pro(启动时 persistSeededProfiles 会写盘) */ export async function getEffectiveCommitProfilePath(ctx: ExtensionContext): Promise { - const stored = ctx.globalState.get>(SETTINGS_KEY) ?? {} + const stored = readStoredSettings(ctx) if (typeof stored.commitProfilePath === 'string') return stored.commitProfilePath const profiles = await listClaudeProfiles() @@ -399,7 +434,7 @@ export async function getEffectiveCommitProfilePath(ctx: ExtensionContext): Prom * 读取改动说明有效的 profile path。规则与提交代码相同:已有 key 原样返回,缺 key 才 seed。 */ export async function getEffectivePrDiffSummaryProfilePath(ctx: ExtensionContext): Promise { - const stored = ctx.globalState.get>(SETTINGS_KEY) ?? {} + const stored = readStoredSettings(ctx) if (typeof stored.prDiffSummaryProfilePath === 'string') return stored.prDiffSummaryProfilePath const profiles = await listClaudeProfiles() @@ -407,7 +442,30 @@ export async function getEffectivePrDiffSummaryProfilePath(ctx: ExtensionContext } export async function saveSettings(ctx: ExtensionContext, next: Partial): Promise { - const current = ctx.globalState.get>(SETTINGS_KEY) ?? {} - const merged: Partial = { ...current, ...next } - await ctx.globalState.update(SETTINGS_KEY, merged) + const merged: Partial = { ...readStoredSettings(ctx), ...next } + writeStoredSettings(ctx, merged) +} + +/** + * 启动时:把旧 memento 迁到文件;commit / 改动说明 profile 缺 key 且能 seed 则写盘。 + * 空串视为用户主动清空,不覆盖。 + */ +export async function persistSeededProfiles(ctx: ExtensionContext): Promise { + const stored = readStoredSettings(ctx) + const fileExists = existsSync(settingsFilePath(ctx)) + const profiles = await listClaudeProfiles() + const patch: Partial = {} + if (typeof stored.commitProfilePath !== 'string') { + const seeded = seedCommitProfilePath(undefined, profiles) + if (seeded) + patch.commitProfilePath = seeded + } + if (typeof stored.prDiffSummaryProfilePath !== 'string') { + const seeded = seedCommitProfilePath(undefined, profiles) + if (seeded) + patch.prDiffSummaryProfilePath = seeded + } + const shouldMigrate = !fileExists && Object.keys(stored).length > 0 + if (shouldMigrate || Object.keys(patch).length > 0) + await saveSettings(ctx, patch) }