mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
53d3e05bb4
* feat(ai): provider switcher chip in the Catty Agent chat input Catty Agent currently has no model chip in the chat input — modelPresets is empty for the catty agentId so `hasModelPicker` is false. The provider/model the chat ends up using comes from the global `activeProviderId` / `activeModelId`, which means switching providers requires opening Settings → AI → Providers. Closes the gap surfaced in the original feedback on #1101 (problem 2) and partially addresses #986 (per-context model switching) by going per-agent. State layer adds an `agentProviderMap` (`Record<agentId, providerId>`) alongside the existing `agentModelMap`. Both are written together when the user picks from the new dropdown; together they form a per-agent override that beats the global active provider/model when set. Cross- window sync mirrors the agentModelMap pattern. ChatInput grows a `providerSwitcher` prop carrying the enabled ProviderConfigs, the selected (providerId, modelId), and an onSelect callback. When supplied, the model chip switches from the Cpu glyph to the provider's ProviderIconBadge + `providerName · modelId`, and the popover renders a two-column layout — providers on the left (icon + name + default model caption), that provider's known model(s) on the right. ACP agents (Claude/Codex) are untouched because they plumb their provider through the CLI, not the Vercel AI SDK. AIChatSidePanel resolves `effectiveActiveProvider` / `effectiveActiveModelId` for the catty agent from the per-agent map, falling back to the global selection. handleSend now passes those through to sendToCattyAgent so a provider picked in one Catty session applies everywhere Catty runs. For v1, each provider's right column shows just its configured `defaultModel`. The two-level structure is in place to absorb richer listings (cached /models, manual additions) later without UI surgery — left a note in the popover code. Refs #1101, #986. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * polish(ai): flatten provider picker to a single list Two-level dropdown was theatre — each ProviderConfig exposes exactly one model (its `defaultModel`), so the right column ended up showing the same string already captioned on the left row. Picking a provider implicitly picks its model; one click is enough. Also drops the `p.enabled !== false` filter on the picker list. The user's expectation, confirmed by feedback on the first cut, is that the chat-input list mirrors what Settings → AI → Providers shows. The per-provider `enabled` toggle is an "active-ish" flag, not a visibility gate; hiding disabled providers in the picker silently made everything but the one currently active disappear, which is what the reviewer hit. Pares the popover down to a vertical list — provider icon + name + default-model caption (mono if set, italic "configure default model" hint if not) + a Check on the bound row. Removes the `hoveredProviderId` state along with the right-column rendering since it's no longer driving anything. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * polish(ai): tune provider icon sizes in the chat input Chip icon was sitting at sm (20px badge) and crowded the h-6 toolbar chip alongside the truncated label; popover rows used the same sm, so nothing differentiated the picker from the chip visually. Adds an xs size to ProviderIconBadge (16px badge, 10px glyph, 9px letter fallback) and uses it on the chip. Popover rows step up to md (32px badge, 16px glyph) and the row padding grows accordingly so the larger brand mark has room to breathe — the picker now reads like a list of choices, the chip reads like a status line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): keep per-agent override honest in model fallback + chip label Two codex P1/P2 findings on #1107: P1 — When Catty has a per-agent provider override but no per-agent model (agentModelMap['catty'] empty), cattyAgentModelId fell through to the global `activeModelId`. That id belongs to whichever provider was globally active, not the one Catty is now bound to, so e.g. overriding to DeepSeek without a defaultModel happily sent gpt-4o (global) to DeepSeek and produced a wrong-model error. The fallback is now: stored agent model → override provider's defaultModel → empty. Only the no-override path keeps the activeModelId tail; the no-provider send guard catches the empty case for everyone else. P2 — ChatInput's selectedSwitcherProvider used `?? providers[0]` so the chip always displayed *some* provider even when none was actually bound (selectedProviderId missing). The rest of the pipeline (send guard, agentProviderMap) treats that as "no provider", so the chip was lying. Dropped the fallback; when nothing is bound the chip now shows a generic Cpu glyph + "Select provider" label until the user picks one from the popover. Adds `ai.chat.selectProvider` in en / zh-CN / ru. Refs codex review on #1107. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): purge per-agent binding when the bound provider is deleted Codex local review (round 2) flagged that a saved Catty model id could outlive its provider. The chain: - catty bound to provider A (DeepSeek): agentProviderMap['catty']='A', agentModelMap['catty']='deepseek-v4-flash' - user deletes provider A - cattyAgentProvider falls back to the global active provider (B, OpenAI) - cattyAgentModelId still returned the saved 'deepseek-v4-flash' - send dispatched the DeepSeek model id to OpenAI → wrong-model error Two layers of fix: 1. **Defensive resolution** (AIChatSidePanel) — cattyAgentModelId now looks at the override provider before trusting the stored model id. If the override is stale (provider deleted), the stored model id is treated as orphan and the resolution falls back to the global active selection just like cattyAgentProvider already does. 2. **Cleanup on remove** (useAIState.removeProvider) — when a provider is deleted, any agent whose agentProviderMap entry pointed at it gets both maps cleared (provider override + saved model). Same rationale: that model id is now meaningless against every other provider. Mirrors the existing activeProviderId cleanup. Adds a small agentProviderMapRef so removeProvider can snapshot which agents to clean up without relying on the captured-at-callback-create state (which would be stale). Refs codex local review on #1107. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): include agentProviderMap in settings sync + block empty-model send Codex local review (round 3) found two issues: P2 — `agentProviderMap` was missing from the cross-device sync surface. Added it to the sync key list, the build path, the apply path, and the SyncPayload type alongside the existing `agentModelMap`. Sync tests in `syncPayload.test.ts` now cover the new field on both the build and apply sides. P2 — Provider rows with no `defaultModel` were still clickable in the chat-input picker, so selecting one would save a binding with empty model id; the send path then dispatched an empty model name and the SDK would surface a vague backend error. Two changes: 1. ChatInput disables the row (\`disabled\`, \`aria-disabled\`, dimmed styling, tooltip pointing the user to Settings) when the provider has no \`defaultModel\`. Click is suppressed. 2. AIChatSidePanel \`handleSend\` adds a model-required guard mirroring the existing no-provider guard — surfaces \`ai.chat.noProviderModel\` as an assistant message. Catches stale bindings (e.g. user edited the provider's \`defaultModel\` to empty after binding) before they reach the SDK. Refs codex local review on #1107. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): trim whitespace-only model ids + reconcile orphan bindings on sync Codex local review (round 4) found two more leaks: P2 — Whitespace-only `defaultModel` passed the picker's `disabled` gate (which trims) but every other layer (cattyAgentModelId resolution, the send-guard `!sendActiveModelId` check, the SDK call) compared the raw string. A provider whose default model was " " could still reach the SDK and surface a vague backend error. Normalized: trim at the resolution boundary (catty model fallback chain) and trim before the send-guard's existence check. P2 — Sync apply did not reconcile per-agent bindings against the incoming provider set. A payload could change `providers` without shipping a fresh `agentProviderMap`, leaving local overrides pointing to provider ids the synced set no longer includes — the same ghost- binding bug that `removeProvider` already handles for explicit user deletes. Added `pruneOrphanPerAgentBindings()` that runs after every AI settings apply: it walks `agentProviderMap`, drops any entry whose provider id isn't in the current `providers` list, and clears the saved model id for those agents alongside it (mirroring removeProvider cleanup). A new test in `syncPayload.test.ts` exercises the ghost- binding case end-to-end. Refs codex local review on #1107. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): notify open AI state of cross-device sync apply in the same window Codex local review (round 4) flagged that `applySyncPayload` writes straight to localStorage but `useAIState` only re-reads on browser `storage` events. Those events only fire in *other* windows — the window doing the apply (the one with the active chat panel) keeps showing pre-sync providers and per-agent bindings until reload. Concrete leak: cloud sync swaps in a new provider set, pruning runs, localStorage is correct, but the open Catty chip still references the ghost provider binding until the user reloads. Extracts the existing `AI_STATE_CHANGED_EVENT` constant + `emitAIStateChanged` helper out of `useAIState` and into a new `application/state/aiStateEvents.ts` so non-React call sites (sync apply, future IPC handlers) can fire it without pulling in the hook. After AI settings apply, `syncPayload.ts` walks every AI key it touched (including the agentProviderMap / agentModelMap that the reconcile step may have mutated even when the payload didn't ship them) and emits a same-window nudge. `useAIState`'s existing local listener routes unknown keys back through its storage handler, so each affected piece of React state rehydrates from localStorage. Adds a regression test in `syncPayload.test.ts` that asserts the expected `netcatty:ai-state-changed` keys are dispatched for the providers + per-agent map keys. Refs codex local review on #1107. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
736 lines
33 KiB
TypeScript
736 lines
33 KiB
TypeScript
/**
|
|
* Sync Payload Builders — Single source of truth for constructing and applying
|
|
* the encrypted cloud-sync payload.
|
|
*
|
|
* Both the main window (App.tsx) and the settings window (SettingsSyncTab.tsx)
|
|
* must use these helpers to guarantee every field is included and no data is
|
|
* silently dropped.
|
|
*/
|
|
|
|
import type {
|
|
GroupConfig,
|
|
Host,
|
|
Identity,
|
|
KnownHost,
|
|
PortForwardingRule,
|
|
ProxyProfile,
|
|
SftpBookmark,
|
|
Snippet,
|
|
SSHKey,
|
|
} from '../domain/models';
|
|
import {
|
|
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
|
|
SYNC_PAYLOAD_ENTITY_KEYS,
|
|
hasSyncPayloadEntityData,
|
|
type SyncPayload,
|
|
} from '../domain/sync';
|
|
import {
|
|
nextCustomKeyBindingsSyncVersion,
|
|
parseCustomKeyBindingsStorageRecord,
|
|
serializeCustomKeyBindingsStorageRecord,
|
|
} from '../domain/customKeyBindings';
|
|
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
|
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
|
import { emitAIStateChanged } from './state/aiStateEvents';
|
|
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
|
import {
|
|
STORAGE_KEY_THEME,
|
|
STORAGE_KEY_UI_THEME_LIGHT,
|
|
STORAGE_KEY_UI_THEME_DARK,
|
|
STORAGE_KEY_ACCENT_MODE,
|
|
STORAGE_KEY_COLOR,
|
|
STORAGE_KEY_UI_FONT_FAMILY,
|
|
STORAGE_KEY_UI_LANGUAGE,
|
|
STORAGE_KEY_CUSTOM_CSS,
|
|
STORAGE_KEY_TERM_THEME,
|
|
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
|
STORAGE_KEY_TERM_THEME_DARK,
|
|
STORAGE_KEY_TERM_THEME_LIGHT,
|
|
STORAGE_KEY_TERM_FONT_FAMILY,
|
|
STORAGE_KEY_TERM_FONT_SIZE,
|
|
STORAGE_KEY_TERM_SETTINGS,
|
|
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
|
STORAGE_KEY_EDITOR_WORD_WRAP,
|
|
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
|
STORAGE_KEY_SFTP_AUTO_SYNC,
|
|
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
|
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
|
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
|
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
|
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
|
STORAGE_KEY_CUSTOM_THEMES,
|
|
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
|
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
|
STORAGE_KEY_SHOW_SFTP_TAB,
|
|
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
|
STORAGE_KEY_AI_PROVIDERS,
|
|
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
|
STORAGE_KEY_AI_ACTIVE_MODEL,
|
|
STORAGE_KEY_AI_PERMISSION_MODE,
|
|
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
|
|
STORAGE_KEY_AI_HOST_PERMISSIONS,
|
|
STORAGE_KEY_AI_DEFAULT_AGENT,
|
|
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
|
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
|
STORAGE_KEY_AI_MAX_ITERATIONS,
|
|
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
|
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
|
STORAGE_KEY_AI_WEB_SEARCH,
|
|
STORAGE_KEY_PORT_FORWARDING,
|
|
} from '../infrastructure/config/storageKeys';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Input types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN = 'sync-payload';
|
|
|
|
/** Vault-owned data. Some fields are local-only and excluded from cloud sync. */
|
|
export interface SyncableVaultData {
|
|
hosts: Host[];
|
|
keys: SSHKey[];
|
|
identities: Identity[];
|
|
proxyProfiles?: ProxyProfile[];
|
|
snippets: Snippet[];
|
|
customGroups: string[];
|
|
snippetPackages?: string[];
|
|
/** Local trust records. Kept in local backups, excluded from cloud sync. */
|
|
knownHosts: KnownHost[];
|
|
groupConfigs?: GroupConfig[];
|
|
}
|
|
|
|
/**
|
|
* Returns true when the payload contains any meaningful user data worth
|
|
* protecting or syncing.
|
|
*/
|
|
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
|
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
|
|
|
return Boolean(
|
|
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns true when a payload contains cloud-sync data.
|
|
* Local-only trust records are intentionally ignored.
|
|
*/
|
|
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
|
if (hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
|
|
|
return Boolean(
|
|
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
|
);
|
|
}
|
|
|
|
export function sanitizePortForwardingRulesForSync(
|
|
rules: PortForwardingRule[] | undefined,
|
|
): PortForwardingRule[] | undefined {
|
|
if (!rules) return rules;
|
|
return rules.map((rule) => ({
|
|
...rule,
|
|
status: 'inactive' as const,
|
|
error: undefined,
|
|
lastUsedAt: undefined,
|
|
}));
|
|
}
|
|
|
|
export function getEffectivePortForwardingRulesForSync(
|
|
rules: PortForwardingRule[] | undefined,
|
|
): PortForwardingRule[] | undefined {
|
|
let effectiveRules = rules;
|
|
if (!effectiveRules || effectiveRules.length === 0) {
|
|
const stored = localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING);
|
|
if (Array.isArray(stored) && stored.length > 0) {
|
|
effectiveRules = stored;
|
|
}
|
|
}
|
|
|
|
return sanitizePortForwardingRulesForSync(effectiveRules);
|
|
}
|
|
|
|
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
|
interface SyncPayloadImporters {
|
|
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
|
|
importVaultData: (jsonString: string) => void | Promise<void>;
|
|
/** Import port-forwarding rules (lives outside the vault hook). */
|
|
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
|
|
/** Called after synced settings have been written to localStorage. */
|
|
onSettingsApplied?: () => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Settings sync helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Terminal settings keys that are safe to sync (platform-agnostic). */
|
|
const SYNCABLE_TERMINAL_KEYS = [
|
|
'startupCommandDelayMs',
|
|
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
|
|
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
|
|
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
|
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
|
'smoothScrolling',
|
|
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
|
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
|
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
|
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
|
|
'serverStatsRefreshInterval', 'rendererType',
|
|
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
|
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
|
] as const;
|
|
|
|
export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
|
STORAGE_KEY_THEME,
|
|
STORAGE_KEY_UI_THEME_LIGHT,
|
|
STORAGE_KEY_UI_THEME_DARK,
|
|
STORAGE_KEY_ACCENT_MODE,
|
|
STORAGE_KEY_COLOR,
|
|
STORAGE_KEY_UI_FONT_FAMILY,
|
|
STORAGE_KEY_UI_LANGUAGE,
|
|
STORAGE_KEY_CUSTOM_CSS,
|
|
STORAGE_KEY_TERM_THEME,
|
|
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
|
STORAGE_KEY_TERM_THEME_DARK,
|
|
STORAGE_KEY_TERM_THEME_LIGHT,
|
|
STORAGE_KEY_TERM_FONT_FAMILY,
|
|
STORAGE_KEY_TERM_FONT_SIZE,
|
|
STORAGE_KEY_TERM_SETTINGS,
|
|
STORAGE_KEY_CUSTOM_THEMES,
|
|
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
|
STORAGE_KEY_EDITOR_WORD_WRAP,
|
|
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
|
STORAGE_KEY_SFTP_AUTO_SYNC,
|
|
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
|
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
|
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
|
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
|
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
|
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
|
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
|
STORAGE_KEY_SHOW_SFTP_TAB,
|
|
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
|
STORAGE_KEY_AI_PROVIDERS,
|
|
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
|
STORAGE_KEY_AI_ACTIVE_MODEL,
|
|
STORAGE_KEY_AI_PERMISSION_MODE,
|
|
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
|
|
STORAGE_KEY_AI_HOST_PERMISSIONS,
|
|
STORAGE_KEY_AI_DEFAULT_AGENT,
|
|
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
|
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
|
STORAGE_KEY_AI_MAX_ITERATIONS,
|
|
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
|
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
|
STORAGE_KEY_AI_WEB_SEARCH,
|
|
] as const;
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
|
|
const readArraySetting = <T = Record<string, unknown>>(key: string): T[] | null => {
|
|
const value = localStorageAdapter.read<T[]>(key);
|
|
return Array.isArray(value) ? value : null;
|
|
};
|
|
|
|
const readRecordSetting = <T extends Record<string, unknown> = Record<string, unknown>>(key: string): T | null => {
|
|
const value = localStorageAdapter.read<T>(key);
|
|
return isRecord(value) ? value as T : null;
|
|
};
|
|
|
|
const stripDeviceBoundApiKey = <T extends Record<string, unknown>>(value: T): T => {
|
|
if (!isEncryptedCredentialPlaceholder(value.apiKey as string | undefined)) return value;
|
|
const next = { ...value };
|
|
delete next.apiKey;
|
|
return next;
|
|
};
|
|
|
|
/**
|
|
* `collectSyncableSettings` strips device-bound encrypted apiKeys before upload,
|
|
* so an incoming providers array typically has no apiKey for providers that
|
|
* already exist locally. Re-attach the local apiKey by id; without this merge,
|
|
* applying any synced settings change would silently wipe credentials on the
|
|
* receiving device.
|
|
*/
|
|
const mergeAiProvidersPreservingLocalApiKeys = (
|
|
incoming: Array<Record<string, unknown>>,
|
|
): Array<Record<string, unknown>> => {
|
|
const local = readArraySetting(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
|
const localById = new Map<string, Record<string, unknown>>();
|
|
for (const provider of local) {
|
|
if (typeof provider?.id === 'string') localById.set(provider.id, provider);
|
|
}
|
|
return incoming.map((provider) => {
|
|
if (provider.apiKey != null) return provider;
|
|
const id = typeof provider.id === 'string' ? provider.id : undefined;
|
|
const localProvider = id != null ? localById.get(id) : undefined;
|
|
if (localProvider && typeof localProvider.apiKey === 'string') {
|
|
return { ...provider, apiKey: localProvider.apiKey };
|
|
}
|
|
return provider;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Same rationale as `mergeAiProvidersPreservingLocalApiKeys`. Only restores the
|
|
* local apiKey when the incoming config still points at the same providerId —
|
|
* switching providers must not silently leak a key meant for a different one.
|
|
*/
|
|
const mergeWebSearchConfigPreservingLocalApiKey = (
|
|
incoming: Record<string, unknown>,
|
|
): Record<string, unknown> => {
|
|
if (incoming.apiKey != null) return incoming;
|
|
const local = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
|
if (!local || typeof local.apiKey !== 'string') return incoming;
|
|
if (local.providerId !== incoming.providerId) return incoming;
|
|
return { ...incoming, apiKey: local.apiKey };
|
|
};
|
|
|
|
/**
|
|
* Collect all syncable settings from localStorage.
|
|
*/
|
|
export function collectSyncableSettings(): SyncPayload['settings'] {
|
|
const settings: SyncPayload['settings'] = {};
|
|
|
|
// Theme & Appearance
|
|
const theme = localStorageAdapter.readString(STORAGE_KEY_THEME);
|
|
if (theme === 'light' || theme === 'dark' || theme === 'system') settings.theme = theme;
|
|
const lightUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_LIGHT);
|
|
if (lightUi) settings.lightUiThemeId = lightUi;
|
|
const darkUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_DARK);
|
|
if (darkUi) settings.darkUiThemeId = darkUi;
|
|
const accentMode = localStorageAdapter.readString(STORAGE_KEY_ACCENT_MODE);
|
|
if (accentMode === 'theme' || accentMode === 'custom') settings.accentMode = accentMode;
|
|
const accent = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
|
if (accent) settings.customAccent = accent;
|
|
const uiFont = localStorageAdapter.readString(STORAGE_KEY_UI_FONT_FAMILY);
|
|
if (uiFont) settings.uiFontFamilyId = uiFont;
|
|
const lang = localStorageAdapter.readString(STORAGE_KEY_UI_LANGUAGE);
|
|
if (lang) settings.uiLanguage = lang;
|
|
const css = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS);
|
|
if (css != null) settings.customCSS = css;
|
|
|
|
// Terminal
|
|
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
|
if (termTheme) settings.terminalTheme = termTheme;
|
|
const followAppTermTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
|
|
if (followAppTermTheme === 'true' || followAppTermTheme === 'false') {
|
|
settings.followAppTerminalTheme = followAppTermTheme === 'true';
|
|
}
|
|
const termThemeDark = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK);
|
|
if (termThemeDark) settings.terminalThemeDark = termThemeDark;
|
|
const termThemeLight = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT);
|
|
if (termThemeLight) settings.terminalThemeLight = termThemeLight;
|
|
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
|
if (termFont) settings.terminalFontFamily = termFont;
|
|
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
|
if (termSize != null) settings.terminalFontSize = termSize;
|
|
|
|
// Terminal settings (syncable subset only)
|
|
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
|
if (termSettingsRaw) {
|
|
try {
|
|
const full = JSON.parse(termSettingsRaw);
|
|
const subset: Record<string, unknown> = {};
|
|
for (const key of SYNCABLE_TERMINAL_KEYS) {
|
|
if (key in full) subset[key] = full[key];
|
|
}
|
|
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
|
|
} catch { /* ignore corrupt data */ }
|
|
}
|
|
|
|
// Custom terminal themes
|
|
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
|
|
if (customThemesRaw) {
|
|
try {
|
|
const parsed = JSON.parse(customThemesRaw);
|
|
if (Array.isArray(parsed)) settings.customTerminalThemes = parsed;
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// Keyboard
|
|
const kb = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
|
if (kb) {
|
|
const parsed = parseCustomKeyBindingsStorageRecord(kb);
|
|
if (parsed) settings.customKeyBindings = parsed.bindings;
|
|
}
|
|
|
|
// Editor
|
|
const wordWrap = localStorageAdapter.readString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
|
if (wordWrap === 'true' || wordWrap === 'false') settings.editorWordWrap = wordWrap === 'true';
|
|
|
|
// SFTP
|
|
const dblClick = localStorageAdapter.readString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
|
if (dblClick === 'open' || dblClick === 'transfer') settings.sftpDoubleClickBehavior = dblClick;
|
|
const autoSync = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_SYNC);
|
|
if (autoSync === 'true' || autoSync === 'false') settings.sftpAutoSync = autoSync === 'true';
|
|
const hidden = localStorageAdapter.readString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
|
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
|
|
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
|
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
|
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
|
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
|
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
|
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
|
|
|
|
// SFTP Bookmarks (global only — local bookmarks are device-specific)
|
|
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
|
|
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
|
|
|
|
|
|
const showRecent = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
|
if (showRecent != null) settings.showRecentHosts = showRecent;
|
|
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
|
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
|
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
|
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
|
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
|
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
|
|
settings.workspaceFocusStyle = workspaceFocusStyle;
|
|
}
|
|
|
|
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
|
|
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
|
|
if (providers) ai.providers = providers.map(stripDeviceBoundApiKey);
|
|
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
|
|
if (activeProviderId != null) ai.activeProviderId = activeProviderId;
|
|
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
|
|
if (activeModelId != null) ai.activeModelId = activeModelId;
|
|
const permissionMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
|
if (permissionMode === 'observer' || permissionMode === 'confirm' || permissionMode === 'autonomous') {
|
|
ai.globalPermissionMode = permissionMode;
|
|
}
|
|
const toolIntegrationMode = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
|
|
if (toolIntegrationMode === 'mcp' || toolIntegrationMode === 'skills') {
|
|
ai.toolIntegrationMode = toolIntegrationMode;
|
|
}
|
|
const hostPermissions = readArraySetting(STORAGE_KEY_AI_HOST_PERMISSIONS);
|
|
if (hostPermissions) ai.hostPermissions = hostPermissions;
|
|
// externalAgents intentionally not collected: command/args/env are device-local.
|
|
const defaultAgentId = localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT);
|
|
if (defaultAgentId != null) ai.defaultAgentId = defaultAgentId;
|
|
const commandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
|
if (Array.isArray(commandBlocklist)) ai.commandBlocklist = commandBlocklist;
|
|
const commandTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT);
|
|
if (commandTimeout != null && Number.isFinite(commandTimeout)) ai.commandTimeout = commandTimeout;
|
|
const maxIterations = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS);
|
|
if (maxIterations != null && Number.isFinite(maxIterations)) ai.maxIterations = maxIterations;
|
|
const agentModelMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
|
if (agentModelMap) ai.agentModelMap = agentModelMap;
|
|
const agentProviderMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
|
|
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
|
|
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
|
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
|
|
if (Object.keys(ai).length > 0) settings.ai = ai;
|
|
|
|
return Object.keys(settings).length > 0 ? settings : undefined;
|
|
}
|
|
|
|
/**
|
|
* Apply synced settings to localStorage. Merges terminal settings
|
|
* to preserve platform-specific fields.
|
|
*/
|
|
function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
|
|
// Theme & Appearance
|
|
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
|
|
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
|
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
|
|
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
|
|
if (settings.customAccent != null) localStorageAdapter.writeString(STORAGE_KEY_COLOR, settings.customAccent);
|
|
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
|
|
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
|
|
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
|
|
|
|
// Terminal
|
|
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
|
|
if (settings.followAppTerminalTheme != null) {
|
|
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(settings.followAppTerminalTheme));
|
|
}
|
|
if (settings.terminalThemeDark != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, settings.terminalThemeDark);
|
|
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
|
|
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
|
|
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
|
|
|
|
// Terminal settings — merge with existing to preserve platform-specific keys
|
|
if (settings.terminalSettings) {
|
|
let existing: Record<string, unknown> = {};
|
|
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
|
if (raw) {
|
|
try { existing = JSON.parse(raw); } catch { /* ignore */ }
|
|
}
|
|
const merged = { ...existing };
|
|
for (const key of SYNCABLE_TERMINAL_KEYS) {
|
|
if (key in settings.terminalSettings) {
|
|
merged[key] = settings.terminalSettings[key];
|
|
}
|
|
}
|
|
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
|
|
}
|
|
|
|
// Custom terminal themes
|
|
if (settings.customTerminalThemes != null) {
|
|
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_THEMES, JSON.stringify(settings.customTerminalThemes));
|
|
}
|
|
|
|
// Keyboard
|
|
if (settings.customKeyBindings != null) {
|
|
const previous = parseCustomKeyBindingsStorageRecord(
|
|
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS),
|
|
);
|
|
localStorageAdapter.writeString(
|
|
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
|
serializeCustomKeyBindingsStorageRecord({
|
|
version: nextCustomKeyBindingsSyncVersion(previous?.version || 0),
|
|
origin: CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN,
|
|
bindings: settings.customKeyBindings,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Editor
|
|
if (settings.editorWordWrap != null) localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(settings.editorWordWrap));
|
|
|
|
// SFTP
|
|
if (settings.sftpDoubleClickBehavior != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, settings.sftpDoubleClickBehavior);
|
|
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
|
|
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
|
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
|
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
|
|
if (settings.sftpDefaultViewMode != null) {
|
|
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, settings.sftpDefaultViewMode);
|
|
}
|
|
|
|
// SFTP Bookmarks (global only)
|
|
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
|
|
|
|
// Immersive mode (legacy — always enabled, ignore incoming value)
|
|
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
|
|
if (settings.showOnlyUngroupedHostsInRoot != null) {
|
|
localStorageAdapter.writeBoolean(
|
|
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
|
settings.showOnlyUngroupedHostsInRoot,
|
|
);
|
|
}
|
|
if (settings.showSftpTab != null) {
|
|
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
|
}
|
|
if (settings.workspaceFocusStyle != null) {
|
|
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
|
|
}
|
|
|
|
const ai = settings.ai;
|
|
if (ai) {
|
|
if (ai.providers != null) {
|
|
localStorageAdapter.write(
|
|
STORAGE_KEY_AI_PROVIDERS,
|
|
mergeAiProvidersPreservingLocalApiKeys(ai.providers),
|
|
);
|
|
}
|
|
if (ai.activeProviderId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, ai.activeProviderId);
|
|
if (ai.activeModelId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, ai.activeModelId);
|
|
if (ai.globalPermissionMode != null) localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, ai.globalPermissionMode);
|
|
if (ai.toolIntegrationMode != null) localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, ai.toolIntegrationMode);
|
|
if (ai.hostPermissions != null) localStorageAdapter.write(STORAGE_KEY_AI_HOST_PERMISSIONS, ai.hostPermissions);
|
|
// externalAgents intentionally not applied: device-local. Legacy snapshots
|
|
// that still carry an `externalAgents` field are silently ignored.
|
|
if (ai.defaultAgentId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, ai.defaultAgentId);
|
|
if (ai.commandBlocklist != null) localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, ai.commandBlocklist);
|
|
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
|
|
if (ai.maxIterations != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, ai.maxIterations);
|
|
if (ai.agentModelMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, ai.agentModelMap);
|
|
if (ai.agentProviderMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, ai.agentProviderMap);
|
|
if (ai.webSearchConfig !== undefined) {
|
|
if (ai.webSearchConfig === null) {
|
|
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
|
|
} else {
|
|
localStorageAdapter.write(
|
|
STORAGE_KEY_AI_WEB_SEARCH,
|
|
mergeWebSearchConfigPreservingLocalApiKey(ai.webSearchConfig),
|
|
);
|
|
}
|
|
}
|
|
// After all AI writes, reconcile per-agent bindings against the final
|
|
// provider list. Sync payloads can land with a new `providers` set but
|
|
// no `agentProviderMap`, or with a stale `agentProviderMap` that
|
|
// points at ids the synced provider set doesn't include — either way
|
|
// we'd leak overrides bound to ghost providers. Mirrors the same
|
|
// cleanup `removeProvider` does for explicit user deletes.
|
|
pruneOrphanPerAgentBindings();
|
|
// Nudge same-window AI state listeners. localStorage writes only fire
|
|
// `storage` events in *other* windows; without this nudge the open
|
|
// chat panel keeps showing pre-sync providers/bindings until reload.
|
|
notifyAIStateAfterSync(ai);
|
|
}
|
|
}
|
|
|
|
function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']): void {
|
|
if (!ai) return;
|
|
// Every AI storage key that `applySyncableSettings` may have touched
|
|
// gets a same-window nudge. `useAIState` listens for these and refreshes
|
|
// the corresponding React state by re-reading localStorage.
|
|
const touched: Array<string> = [];
|
|
if (ai.providers != null) touched.push(STORAGE_KEY_AI_PROVIDERS);
|
|
if (ai.activeProviderId != null) touched.push(STORAGE_KEY_AI_ACTIVE_PROVIDER);
|
|
if (ai.activeModelId != null) touched.push(STORAGE_KEY_AI_ACTIVE_MODEL);
|
|
if (ai.globalPermissionMode != null) touched.push(STORAGE_KEY_AI_PERMISSION_MODE);
|
|
if (ai.toolIntegrationMode != null) touched.push(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
|
|
if (ai.hostPermissions != null) touched.push(STORAGE_KEY_AI_HOST_PERMISSIONS);
|
|
if (ai.defaultAgentId != null) touched.push(STORAGE_KEY_AI_DEFAULT_AGENT);
|
|
if (ai.commandBlocklist != null) touched.push(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
|
if (ai.commandTimeout != null) touched.push(STORAGE_KEY_AI_COMMAND_TIMEOUT);
|
|
if (ai.maxIterations != null) touched.push(STORAGE_KEY_AI_MAX_ITERATIONS);
|
|
if (ai.agentModelMap != null) touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
|
// agentProviderMap is *always* potentially mutated because the reconcile
|
|
// step may have pruned it even if the payload didn't ship one.
|
|
touched.push(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
|
|
// The reconcile may also have pruned saved models alongside provider
|
|
// bindings, so always nudge the model map too.
|
|
if (!touched.includes(STORAGE_KEY_AI_AGENT_MODEL_MAP)) {
|
|
touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
|
}
|
|
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
|
|
for (const key of touched) {
|
|
emitAIStateChanged(key);
|
|
}
|
|
}
|
|
|
|
function pruneOrphanPerAgentBindings(): void {
|
|
const providers = localStorageAdapter.read<Array<{ id?: string }>>(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
|
const validIds = new Set(
|
|
providers
|
|
.map((p) => p?.id)
|
|
.filter((id): id is string => typeof id === 'string' && id.length > 0),
|
|
);
|
|
const providerMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {};
|
|
const modelMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {};
|
|
let providerChanged = false;
|
|
let modelChanged = false;
|
|
const nextProviderMap: Record<string, string> = {};
|
|
const nextModelMap: Record<string, string> = { ...modelMap };
|
|
for (const agentId of Object.keys(providerMap)) {
|
|
const providerId = providerMap[agentId];
|
|
if (providerId && validIds.has(providerId)) {
|
|
nextProviderMap[agentId] = providerId;
|
|
} else {
|
|
providerChanged = true;
|
|
// Drop the saved model too — that id belonged to the now-missing
|
|
// provider and isn't trustworthy against any other binding.
|
|
if (agentId in nextModelMap) {
|
|
delete nextModelMap[agentId];
|
|
modelChanged = true;
|
|
}
|
|
}
|
|
}
|
|
if (providerChanged) {
|
|
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, nextProviderMap);
|
|
}
|
|
if (modelChanged) {
|
|
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, nextModelMap);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build a complete `SyncPayload` from local data.
|
|
*
|
|
* Port-forwarding rules are optional because they are managed by a separate
|
|
* state hook (`usePortForwardingState`). Callers should strip transient
|
|
* runtime fields (status, error, lastUsedAt) before passing them in.
|
|
*/
|
|
export function buildSyncPayload(
|
|
vault: SyncableVaultData,
|
|
portForwardingRules?: PortForwardingRule[],
|
|
): SyncPayload {
|
|
return {
|
|
hosts: vault.hosts,
|
|
keys: vault.keys,
|
|
identities: vault.identities,
|
|
proxyProfiles: vault.proxyProfiles,
|
|
snippets: vault.snippets,
|
|
customGroups: vault.customGroups,
|
|
snippetPackages: vault.snippetPackages,
|
|
groupConfigs: vault.groupConfigs,
|
|
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
|
settings: collectSyncableSettings(),
|
|
syncedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
/** Build a local backup/restore payload, including local-only trust records. */
|
|
export function buildLocalVaultPayload(
|
|
vault: SyncableVaultData,
|
|
portForwardingRules?: PortForwardingRule[],
|
|
): SyncPayload {
|
|
return {
|
|
...buildSyncPayload(vault, portForwardingRules),
|
|
knownHosts: vault.knownHosts,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Apply a downloaded `SyncPayload` to local state via the provided importers.
|
|
*
|
|
* This ensures both vault data and port-forwarding rules are imported
|
|
* consistently across windows.
|
|
*/
|
|
function applyPayload(
|
|
payload: SyncPayload,
|
|
importers: SyncPayloadImporters,
|
|
options: { includeLocalOnlyData: boolean },
|
|
): Promise<void> {
|
|
// Build the vault import object. Cloud sync intentionally ignores
|
|
// local-only trust records even if legacy cloud snapshots still carry them.
|
|
const vaultImport: Record<string, unknown> = {
|
|
hosts: payload.hosts,
|
|
keys: payload.keys,
|
|
identities: payload.identities,
|
|
proxyProfiles: payload.proxyProfiles,
|
|
snippets: payload.snippets,
|
|
customGroups: payload.customGroups,
|
|
};
|
|
if (payload.snippetPackages !== undefined) {
|
|
vaultImport.snippetPackages = payload.snippetPackages;
|
|
}
|
|
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
|
|
vaultImport.knownHosts = payload.knownHosts;
|
|
}
|
|
if (Array.isArray(payload.groupConfigs)) {
|
|
vaultImport.groupConfigs = payload.groupConfigs;
|
|
}
|
|
|
|
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(() => {
|
|
// Only import port-forwarding rules when the payload explicitly carries
|
|
// them. Absent field = "payload was created before this feature existed",
|
|
// so local rules are preserved. Explicitly present [] = "remote has no
|
|
// rules, clear local state".
|
|
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
|
importers.importPortForwardingRules(payload.portForwardingRules);
|
|
}
|
|
|
|
// Apply synced settings
|
|
if (payload.settings) {
|
|
applySyncableSettings(payload.settings);
|
|
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
|
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
|
importers.onSettingsApplied?.();
|
|
}
|
|
});
|
|
}
|
|
|
|
export function applySyncPayload(
|
|
payload: SyncPayload,
|
|
importers: SyncPayloadImporters,
|
|
): Promise<void> {
|
|
return applyPayload(payload, importers, { includeLocalOnlyData: false });
|
|
}
|
|
|
|
export function applyLocalVaultPayload(
|
|
payload: SyncPayload,
|
|
importers: SyncPayloadImporters,
|
|
): Promise<void> {
|
|
return applyPayload(payload, importers, { includeLocalOnlyData: true });
|
|
}
|