diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index 4169fea6ffc..2e5b4c285f3 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -5,6 +5,7 @@ import { dirname } from 'node:path' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' import type { ExecutionHostId } from '../../shared/execution-host' import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import { normalizePersistedProjectSourceRepoIds } from '../../shared/project-source-repo-id-normalization' import type { PersistedState, Project, @@ -101,8 +102,9 @@ function isRepoBackedProjectHostSetup( } export function rebuildRepoBackedProjectState(state: TransferProfileState): TransferProfileState { + const projects = normalizePersistedProjectSourceRepoIds(state.projects).projects const projection = projectHostSetupProjectionFromRepos(state.repos) - const existingProjectsById = new Map(state.projects.map((project) => [project.id, project])) + const existingProjectsById = new Map(projects.map((project) => [project.id, project])) const currentRepoIds = new Set(state.repos.map((repo) => repo.id)) const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) @@ -113,7 +115,7 @@ export function rebuildRepoBackedProjectState(state: TransferProfileState): Tran return !isRepoBackedProjectHostSetup(setup, currentRepoIds) }) const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId)) - const independentProjects = state.projects + const independentProjects = projects .filter( (project) => independentProjectIds.has(project.id) && !projectedProjectIds.has(project.id) ) diff --git a/src/main/orca-profiles/profile-project-transfer.test.ts b/src/main/orca-profiles/profile-project-transfer.test.ts index 3c3a4643387..b73833e17cd 100644 --- a/src/main/orca-profiles/profile-project-transfer.test.ts +++ b/src/main/orca-profiles/profile-project-transfer.test.ts @@ -108,6 +108,42 @@ describe('profile project transfer', () => { rmSync(testState.dir, { recursive: true, force: true }) }) + it('normalizes missing project source repo ids before rebuilding profile state', async () => { + const malformedState = makeState({ + projects: [ + { + id: 'folder-project', + displayName: 'Folder Project', + badgeColor: '#737373', + createdAt: 1, + updatedAt: 1 + } + ], + projectHostSetups: [ + { + id: 'folder-project::local', + projectId: 'folder-project', + hostId: 'local', + repoId: '', + path: 'C:\\workspace\\folder-project', + displayName: 'Folder Project', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } + ] + } as Partial) + writeProfileState('personal', malformedState) + + const { readProfileState: readPersistedProfileState } = + await import('./profile-project-state-file') + + expect(readPersistedProfileState('personal', testState.dir).projects).toContainEqual( + expect.objectContaining({ id: 'folder-project', sourceRepoIds: [] }) + ) + }) + it('copies a project into another profile with a new repo id and re-keyed metadata', async () => { const sourceWorktreeId = 'repo-1::/workspace/orca-feature' writeProfileState( diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b59ca6f5986..0bc2084b931 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -562,6 +562,34 @@ describe('Store', () => { expect(persisted.projectHostSetups).toContainEqual(independentSetup) }) + it('normalizes missing persisted project source repo ids before compatibility projection', async () => { + const independentProject = makeProject({ id: 'folder-project' }) + const { sourceRepoIds: _sourceRepoIds, ...malformedProject } = independentProject + const independentSetup = makeProjectHostSetup({ + id: 'folder-project::local', + projectId: independentProject.id, + repoId: '', + path: 'C:\\workspace\\folder-project' + }) + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + projects: [malformedProject], + projectHostSetups: [independentSetup] + }) + + const store = await createStore() + + expect(store.getProjects()).toContainEqual({ ...independentProject, sourceRepoIds: [] }) + expect(() => + store.getProjects().find((project) => project.sourceRepoIds.includes('missing-repo')) + ).not.toThrow() + store.flush() + expect((readDataFile() as PersistedState).projects).toContainEqual({ + ...independentProject, + sourceRepoIds: [] + }) + }) + it('updates and persists a project Windows runtime preference', async () => { const project = makeProject({ id: 'project-1', @@ -5621,6 +5649,42 @@ describe('Store', () => { expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes') }) + it('normalizes terminal cursor style before persistence and listener broadcasts', async () => { + const store = await createStore() + store.updateSettings({ terminalCursorStyle: 'underline' }) + const listener = vi.fn() + store.onSettingsChanged(listener) + + const invalid = store.updateSettings( + { terminalCursorStyle: 'beam' as never }, + { notifyListeners: true } + ) + + expect(invalid.terminalCursorStyle).toBe('block') + expect(invalid.terminalCursorStyleDefaultedToBlock).toBe(true) + expect(listener).toHaveBeenCalledWith( + { + terminalCursorStyle: 'block' + }, + expect.objectContaining({ terminalCursorStyle: 'block' }), + undefined + ) + + const valid = store.updateSettings( + { terminalCursorStyle: 'underline' }, + { notifyListeners: true } + ) + expect(valid.terminalCursorStyle).toBe('underline') + expect(listener).toHaveBeenLastCalledWith( + { terminalCursorStyle: 'underline' }, + expect.objectContaining({ terminalCursorStyle: 'underline' }), + undefined + ) + + store.flush() + expect((readDataFile() as PersistedState).settings.terminalCursorStyle).toBe('underline') + }) + it('normalizes disabled TUI agents on load and update', async () => { writeFileSync( join(testState.dir, 'orca-data.json'), @@ -7295,6 +7359,22 @@ describe('Store', () => { expect(store.getSettings().terminalCursorStyleDefaultedToBlock).toBe(true) }) + it('replaces an invalid persisted terminal cursor choice after migration', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalCursorStyle: 'beam', terminalCursorStyleDefaultedToBlock: true }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getSettings().terminalCursorStyle).toBe('block') + store.flush() + expect((readDataFile() as PersistedState).settings.terminalCursorStyle).toBe('block') + }) + it('preserves explicit "false" terminalMacOptionAsAlt through migration', async () => { // 'false' never matched the old default — it was an explicit choice. writeDataFile({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 12856638d29..83209e47bd8 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -247,6 +247,7 @@ import { normalizeTuiAgentEnvRecord } from '../shared/tui-agent-launch-defaults' import { normalizeTerminalCursorStyleDefault } from '../shared/terminal-cursor-style-settings' +import { normalizePersistedProjectSourceRepoIds } from '../shared/project-source-repo-id-normalization' import { normalizeOsc52ClipboardDefaultOn, osc52ClipboardDefaultOnOverridesPersistedOff @@ -3224,6 +3225,13 @@ export class Store { parsed.settings ) const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) + if ( + parsed.settings?.terminalCursorStyle !== + migratedTerminalCursorStyle.terminalCursorStyle || + parsed.settings?.terminalCursorStyleDefaultedToBlock !== true + ) { + this.loadNeedsSave = true + } const migratedTerminalLineHeight = normalizeTerminalLineHeight( parsed.settings?.terminalLineHeight ) @@ -3734,6 +3742,11 @@ export class Store { this.loadNeedsSave = true } + const normalizedProjects = normalizePersistedProjectSourceRepoIds(result.projects) + if (normalizedProjects.changed) { + this.loadNeedsSave = true + result = { ...result, projects: normalizedProjects.projects } + } const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { @@ -5804,6 +5817,15 @@ export class Store { updates.terminalCustomThemes ) } + if ('terminalCursorStyle' in updates) { + Object.assign( + sanitizedUpdates, + normalizeTerminalCursorStyleDefault( + { terminalCursorStyle: updates.terminalCursorStyle }, + { preserveExplicitValue: true } + ) + ) + } if ('terminalScrollbackRows' in updates) { sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( updates.terminalScrollbackRows diff --git a/src/renderer/src/store/plugin-language-packs.test.ts b/src/renderer/src/store/plugin-language-packs.test.ts new file mode 100644 index 00000000000..664405a26e0 --- /dev/null +++ b/src/renderer/src/store/plugin-language-packs.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { i18n, setRendererPluginLanguagePacks } from '../i18n/i18n' +import { pluginLanguageResourceId } from '../../../shared/plugins/plugin-language-pack-artifact' +import { usePluginLanguagePackStore } from './plugin-language-packs' + +afterEach(() => { + vi.restoreAllMocks() + setRendererPluginLanguagePacks([]) + usePluginLanguagePackStore.setState({ packs: [], loaded: false }) + vi.unstubAllGlobals() +}) + +describe('plugin language pack loading', () => { + it('rejects malformed persisted language-pack results at the renderer ingress', async () => { + vi.stubGlobal('window', { + api: { + plugins: { + listLanguagePacks: vi.fn().mockResolvedValue({ malformed: true }) + } + } + }) + + await usePluginLanguagePackStore.getState().fetchPacks() + + const state = usePluginLanguagePackStore.getState() + expect(state.loaded).toBe(true) + expect(state.packs).toEqual([]) + expect(() => state.packs.find(() => true)).not.toThrow() + }) + + it('filters malformed language-pack array members without changing valid order', async () => { + const first = { + id: 'plugin:first', + resourceLanguage: pluginLanguageResourceId('plugin:first'), + pluginKey: 'first', + locale: 'pt-BR', + catalog: { greeting: 'Olá' } + } + const second = { + id: 'plugin:second', + resourceLanguage: pluginLanguageResourceId('plugin:second'), + pluginKey: 'second', + locale: 'de', + catalog: {} + } + vi.stubGlobal('window', { + api: { + plugins: { + listLanguagePacks: vi + .fn() + .mockResolvedValue([null, first, { ...first, catalog: [] }, second]) + } + } + }) + + await usePluginLanguagePackStore.getState().fetchPacks() + + expect(usePluginLanguagePackStore.getState().packs).toEqual([first, second]) + }) + + it('rejects an inconsistent resource language before i18next owns its cleanup', async () => { + vi.stubGlobal('window', { + api: { + plugins: { + listLanguagePacks: vi.fn().mockResolvedValue([ + { + id: 'plugin:bad', + resourceLanguage: 'plugin.bad', + pluginKey: 'bad', + locale: 'en', + catalog: {} + } + ]) + } + } + }) + + await usePluginLanguagePackStore.getState().fetchPacks() + setRendererPluginLanguagePacks(usePluginLanguagePackStore.getState().packs) + const removeResourceBundle = vi + .spyOn(i18n, 'removeResourceBundle') + .mockImplementation((language) => { + throw new Error(`invalid resource language: ${language}`) + }) + + expect(() => setRendererPluginLanguagePacks([])).not.toThrow() + expect(removeResourceBundle).not.toHaveBeenCalled() + }) + + it('keeps the latest request when an older language-pack response finishes last', async () => { + const older = { + id: 'plugin:older', + resourceLanguage: pluginLanguageResourceId('plugin:older'), + pluginKey: 'older', + locale: 'de', + catalog: {} + } + const latest = { + id: 'plugin:latest', + resourceLanguage: pluginLanguageResourceId('plugin:latest'), + pluginKey: 'latest', + locale: 'fr', + catalog: {} + } + let resolveOlder!: (value: unknown[]) => void + const olderResponse = new Promise((resolve) => { + resolveOlder = resolve + }) + vi.stubGlobal('window', { + api: { + plugins: { + listLanguagePacks: vi + .fn() + .mockReturnValueOnce(olderResponse) + .mockResolvedValueOnce([latest]) + } + } + }) + + const olderFetch = usePluginLanguagePackStore.getState().fetchPacks() + await usePluginLanguagePackStore.getState().fetchPacks() + resolveOlder([older]) + await olderFetch + + expect(usePluginLanguagePackStore.getState().packs).toEqual([latest]) + }) +}) diff --git a/src/renderer/src/store/plugin-language-packs.ts b/src/renderer/src/store/plugin-language-packs.ts index a237f80f6fc..9a688c20d8d 100644 --- a/src/renderer/src/store/plugin-language-packs.ts +++ b/src/renderer/src/store/plugin-language-packs.ts @@ -1,6 +1,9 @@ import { useEffect } from 'react' import { create } from 'zustand' -import type { PluginLanguagePackRegistration } from '../../../shared/plugins/plugin-language-pack-artifact' +import { + isPluginLanguagePackRegistration, + type PluginLanguagePackRegistration +} from '../../../shared/plugins/plugin-language-pack-artifact' type PluginLanguagePackState = { packs: PluginLanguagePackRegistration[] @@ -24,7 +27,11 @@ export const usePluginLanguagePackStore = create()((set return } try { - const packs = await api.listLanguagePacks() + const response = await api.listLanguagePacks() + const packs = Array.isArray(response) ? response.filter(isPluginLanguagePackRegistration) : [] + if (!Array.isArray(response) || packs.length !== response.length) { + console.warn('[plugins] Ignoring malformed language-pack list') + } if (generation === requestGeneration) { set({ packs, loaded: true }) } diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 92dd209d047..31ec078847d 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -901,6 +901,26 @@ describe('web settings preload API', () => { expect(settings.terminalCursorStyleDefaultedToBlock).toBe(true) }) + it('normalizes terminal cursor style before web settings writes return or persist', async () => { + const { api, storage } = await installApi('Linux') + + const invalid = await api.settings.set({ terminalCursorStyle: 'beam' as never }) + const invalidStored = JSON.parse(storage.getItem('orca.web.settings.v1') ?? '{}') as { + terminalCursorStyle?: string + terminalCursorStyleDefaultedToBlock?: boolean + } + expect(invalid.terminalCursorStyle).toBe('block') + expect(invalid.terminalCursorStyleDefaultedToBlock).toBe(true) + expect(invalidStored.terminalCursorStyle).toBe('block') + expect(invalidStored.terminalCursorStyleDefaultedToBlock).toBe(true) + + const valid = await api.settings.set({ terminalCursorStyle: 'bar' }) + expect(valid.terminalCursorStyle).toBe('bar') + expect(JSON.parse(storage.getItem('orca.web.settings.v1') ?? '{}').terminalCursorStyle).toBe( + 'bar' + ) + }) + it('migrates OSC 52 clipboard writes on for stored web settings once', async () => { // Why: the web store is a second, independent settings store — the constants-level // default flip only reaches profiles that never persisted the old `false` (#10567). diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 6f049d237c8..175ade258f9 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -680,6 +680,15 @@ function createWebPreloadApi(): Partial { if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) { sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true } + if ('terminalCursorStyle' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + normalizeTerminalCursorStyleDefault( + { terminalCursorStyle: sanitizedUpdates.terminalCursorStyle }, + { preserveExplicitValue: true } + ) + ) + } const next = mergeSettings(getStoredSettings(), sanitizedUpdates, { preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates }) diff --git a/src/shared/plugins/plugin-language-pack-artifact.ts b/src/shared/plugins/plugin-language-pack-artifact.ts index 917adeef69e..c344184479c 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.ts @@ -23,6 +23,26 @@ export type PluginLanguagePackRegistration = { catalog: Record } +export function isPluginLanguagePackRegistration( + value: unknown +): value is PluginLanguagePackRegistration { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false + } + const pack = value as Record + return ( + typeof pack.id === 'string' && + pack.id.startsWith('plugin:') && + typeof pack.resourceLanguage === 'string' && + pack.resourceLanguage === pluginLanguageResourceId(pack.id as `plugin:${string}`) && + typeof pack.pluginKey === 'string' && + typeof pack.locale === 'string' && + typeof pack.catalog === 'object' && + pack.catalog !== null && + !Array.isArray(pack.catalog) + ) +} + export function pluginLanguageResourceId(id: `plugin:${string}`): `plugin${string}` { let encoded = '' for (let index = 0; index < id.length; index += 1) { diff --git a/src/shared/project-source-repo-id-normalization.ts b/src/shared/project-source-repo-id-normalization.ts new file mode 100644 index 00000000000..11fdeb7e5ec --- /dev/null +++ b/src/shared/project-source-repo-id-normalization.ts @@ -0,0 +1,24 @@ +import type { Project } from './types' + +export function normalizePersistedProjectSourceRepoIds(projects: readonly Project[]): { + projects: Project[] + changed: boolean +} { + let changed = false + const normalizedProjects = projects.map((project) => { + if ( + Array.isArray(project.sourceRepoIds) && + project.sourceRepoIds.every((repoId) => typeof repoId === 'string') + ) { + return project + } + changed = true + return { + ...project, + sourceRepoIds: Array.isArray(project.sourceRepoIds) + ? project.sourceRepoIds.filter((repoId): repoId is string => typeof repoId === 'string') + : [] + } + }) + return { projects: normalizedProjects, changed } +} diff --git a/src/shared/terminal-cursor-style-settings.ts b/src/shared/terminal-cursor-style-settings.ts index 0de07a08e31..ec271a7b972 100644 --- a/src/shared/terminal-cursor-style-settings.ts +++ b/src/shared/terminal-cursor-style-settings.ts @@ -5,6 +5,10 @@ type TerminalCursorStyleSettings = Pick< 'terminalCursorStyle' | 'terminalCursorStyleDefaultedToBlock' > +function isTerminalCursorStyle(value: unknown): value is GlobalSettings['terminalCursorStyle'] { + return value === 'bar' || value === 'block' || value === 'underline' +} + export function normalizeTerminalCursorStyleDefault( settings: Partial | undefined, options: { preserveExplicitValue?: boolean } = {} @@ -15,7 +19,10 @@ export function normalizeTerminalCursorStyleDefault( return { // Why: prior builds persisted the old bar default into profiles; migrate // those inherited values once while preserving later explicit choices. - terminalCursorStyle: defaultedToBlock ? (settings?.terminalCursorStyle ?? 'block') : 'block', + terminalCursorStyle: + defaultedToBlock && isTerminalCursorStyle(settings?.terminalCursorStyle) + ? settings.terminalCursorStyle + : 'block', terminalCursorStyleDefaultedToBlock: true } }