🐛 fix(vscode): 设置写入 globalStorage 文件,更新不再丢提交 profile
This commit is contained in:
1 parent
3a912fce3e
commit
b2d0d17584
3 files changed
+164
-14
No files matched your search
@@ -9,7 +9,7 @@ import { initIdentity } from './auth/identity'
|
|||||||
import { setProfilesDirOverride } from './cc/profiles'
|
import { setProfilesDirOverride } from './cc/profiles'
|
||||||
import { logger } from './logging/logger'
|
import { logger } from './logging/logger'
|
||||||
import { KanbanWebviewPanel } from './panel/KanbanPanel'
|
import { KanbanWebviewPanel } from './panel/KanbanPanel'
|
||||||
import { getSettings } from './settings/store'
|
import { getSettings, persistSeededProfiles } from './settings/store'
|
||||||
import { registerCreateSessionUriHandler } from './uri/createSessionUriHandler'
|
import { registerCreateSessionUriHandler } from './uri/createSessionUriHandler'
|
||||||
import { webhookCoordinator } from './webhook/coordinator'
|
import { webhookCoordinator } from './webhook/coordinator'
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ class EmptyTreeProvider implements TreeDataProvider<never> {
|
|||||||
export function activate(context: ExtensionContext): void {
|
export function activate(context: ExtensionContext): void {
|
||||||
// Profiles 目录 override 在 panel 未打开时也要生效(list / launch 共用)。
|
// Profiles 目录 override 在 panel 未打开时也要生效(list / launch 共用)。
|
||||||
setProfilesDirOverride(getSettings(context).profilesDirectory || undefined)
|
setProfilesDirOverride(getSettings(context).profilesDirectory || undefined)
|
||||||
|
void persistSeededProfiles(context)
|
||||||
// Start the webhook server immediately so PR callbacks are received even
|
// Start the webhook server immediately so PR callbacks are received even
|
||||||
// when the Kanban panel is closed. Disposed automatically via subscriptions.
|
// when the Kanban panel is closed. Disposed automatically via subscriptions.
|
||||||
webhookCoordinator.init(context)
|
webhookCoordinator.init(context)
|
||||||
|
|||||||
@@ -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<string, unknown> = {}): ExtensionContext {
|
||||||
|
return {
|
||||||
|
extensionPath: path.resolve(__dirname, '../..'),
|
||||||
|
globalStorageUri: { fsPath: dir } as Uri,
|
||||||
|
globalState: {
|
||||||
|
get: <T>(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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
* Token stays in `context.secrets` (see `src/auth/secrets.ts`).
|
||||||
* else (webhook port, prompt templates) lives here under a single JSON blob
|
* 读:文件 → 旧 globalState(一次性迁移)。写:只写文件。
|
||||||
* keyed by `SETTINGS_KEY`.
|
|
||||||
*
|
*
|
||||||
* Empty-string values for prompts are treated as "use the default" so the
|
* 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
|
* form can save user input verbatim and still fall back when the user clears
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExtensionContext } from 'vscode'
|
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 path from 'node:path'
|
||||||
import { seedCommitProfilePath } from '../cc/commitProfile'
|
import { seedCommitProfilePath } from '../cc/commitProfile'
|
||||||
import { listClaudeProfiles } from '../cc/profiles'
|
import { listClaudeProfiles } from '../cc/profiles'
|
||||||
@@ -208,6 +209,40 @@ export interface Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SETTINGS_KEY = 'superpowers.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<Settings> | null {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
||||||
|
return null
|
||||||
|
return raw as Partial<Settings>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件优先;没有文件时读旧 globalState,供迁移。 */
|
||||||
|
export function readStoredSettings(ctx: ExtensionContext): Partial<Settings> {
|
||||||
|
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<Settings>): 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 {
|
function defaults(ctx: ExtensionContext): Settings {
|
||||||
return {
|
return {
|
||||||
@@ -242,7 +277,7 @@ function defaults(ctx: ExtensionContext): Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSettings(ctx: ExtensionContext): Settings {
|
export function getSettings(ctx: ExtensionContext): Settings {
|
||||||
const stored = ctx.globalState.get<Partial<Settings>>(SETTINGS_KEY) ?? {}
|
const stored = readStoredSettings(ctx)
|
||||||
const base = defaults(ctx)
|
const base = defaults(ctx)
|
||||||
|
|
||||||
const webhookPort = typeof stored.webhookPort === 'number' && Number.isInteger(stored.webhookPort)
|
const webhookPort = typeof stored.webhookPort === 'number' && Number.isInteger(stored.webhookPort)
|
||||||
@@ -384,11 +419,11 @@ export function getSettings(ctx: ExtensionContext): Settings {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取工具栏「提交代码」有效的 profile path。
|
* 读取工具栏「提交代码」有效的 profile path。
|
||||||
* ① raw stored 已有 key(含用户清空的 '')→ 原样返回
|
* ① stored 已有 key(含用户清空的 '')→ 原样返回
|
||||||
* ② key 从未出现 → list profiles 后 seed deepseek-v4-pro(不写回磁盘)
|
* ② key 从未出现 → list profiles 后 seed deepseek-v4-pro(启动时 persistSeededProfiles 会写盘)
|
||||||
*/
|
*/
|
||||||
export async function getEffectiveCommitProfilePath(ctx: ExtensionContext): Promise<string> {
|
export async function getEffectiveCommitProfilePath(ctx: ExtensionContext): Promise<string> {
|
||||||
const stored = ctx.globalState.get<Partial<Settings>>(SETTINGS_KEY) ?? {}
|
const stored = readStoredSettings(ctx)
|
||||||
if (typeof stored.commitProfilePath === 'string')
|
if (typeof stored.commitProfilePath === 'string')
|
||||||
return stored.commitProfilePath
|
return stored.commitProfilePath
|
||||||
const profiles = await listClaudeProfiles()
|
const profiles = await listClaudeProfiles()
|
||||||
@@ -399,7 +434,7 @@ export async function getEffectiveCommitProfilePath(ctx: ExtensionContext): Prom
|
|||||||
* 读取改动说明有效的 profile path。规则与提交代码相同:已有 key 原样返回,缺 key 才 seed。
|
* 读取改动说明有效的 profile path。规则与提交代码相同:已有 key 原样返回,缺 key 才 seed。
|
||||||
*/
|
*/
|
||||||
export async function getEffectivePrDiffSummaryProfilePath(ctx: ExtensionContext): Promise<string> {
|
export async function getEffectivePrDiffSummaryProfilePath(ctx: ExtensionContext): Promise<string> {
|
||||||
const stored = ctx.globalState.get<Partial<Settings>>(SETTINGS_KEY) ?? {}
|
const stored = readStoredSettings(ctx)
|
||||||
if (typeof stored.prDiffSummaryProfilePath === 'string')
|
if (typeof stored.prDiffSummaryProfilePath === 'string')
|
||||||
return stored.prDiffSummaryProfilePath
|
return stored.prDiffSummaryProfilePath
|
||||||
const profiles = await listClaudeProfiles()
|
const profiles = await listClaudeProfiles()
|
||||||
@@ -407,7 +442,30 @@ export async function getEffectivePrDiffSummaryProfilePath(ctx: ExtensionContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSettings(ctx: ExtensionContext, next: Partial<Settings>): Promise<void> {
|
export async function saveSettings(ctx: ExtensionContext, next: Partial<Settings>): Promise<void> {
|
||||||
const current = ctx.globalState.get<Partial<Settings>>(SETTINGS_KEY) ?? {}
|
const merged: Partial<Settings> = { ...readStoredSettings(ctx), ...next }
|
||||||
const merged: Partial<Settings> = { ...current, ...next }
|
writeStoredSettings(ctx, merged)
|
||||||
await ctx.globalState.update(SETTINGS_KEY, merged)
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动时:把旧 memento 迁到文件;commit / 改动说明 profile 缺 key 且能 seed 则写盘。
|
||||||
|
* 空串视为用户主动清空,不覆盖。
|
||||||
|
*/
|
||||||
|
export async function persistSeededProfiles(ctx: ExtensionContext): Promise<void> {
|
||||||
|
const stored = readStoredSettings(ctx)
|
||||||
|
const fileExists = existsSync(settingsFilePath(ctx))
|
||||||
|
const profiles = await listClaudeProfiles()
|
||||||
|
const patch: Partial<Settings> = {}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user