From 75e8e8e2830c7f3c32b815bd9b13e34d53d39269 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Thu, 18 Jun 2026 00:35:50 +0530 Subject: [PATCH] feat: add rawDel function for key deletion and enhance sync database schema - Introduced rawDel function to delete keys from the store, supporting both KV and local storage. - Enhanced sync database schema by adding new columns to the sync_accounts table for better tracking of account usage and status. - Updated documentation to include details on the new deletion process for synced items. - Improved AI Assistant features by integrating new AI providers and updating model listing functionalities. - Refactored settings hub UI to support new AI configuration options and enhance user experience. --- api/_lib/store.js | 13 + api/_lib/sync-db.js | 7 + docs/CLOUD_SYNC.md | 13 + src/activation/commandSpecs.ts | 10 +- .../aiAssistant/AiModelCatalogService.ts | 17 +- src/features/aiAssistant/aiConfig.ts | 6 + src/features/aiAssistant/modelListing.ts | 68 + src/features/aiAssistant/types.ts | 12 +- src/features/settings/handlers/ai.ts | 174 ++- src/features/settings/handlers/sync.ts | 3 + src/features/sync/NotebookSyncService.ts | 7 + src/features/sync/SyncActivityLog.ts | 5 + src/features/sync/SyncController.ts | 20 +- templates/settings-hub/index.html | 766 ++++++---- templates/settings-hub/scripts.js | 1307 ++++++++++++++--- templates/settings-hub/styles.css | 620 +++++++- 16 files changed, 2497 insertions(+), 551 deletions(-) diff --git a/api/_lib/store.js b/api/_lib/store.js index 2808349..93f0d12 100644 --- a/api/_lib/store.js +++ b/api/_lib/store.js @@ -94,6 +94,18 @@ async function rawSet(key, value, ttlSec) { writeDevStore(store); } +async function rawDel(key) { + if (useKv) { + await kv().del(key); + return; + } + const store = readDevStore(); + if (key in store) { + delete store[key]; + writeDevStore(store); + } +} + async function writeKvPointers(entitlement) { if (!entitlement.subscriptionId && !entitlement.email) return; if (entitlement.subscriptionId) { @@ -296,5 +308,6 @@ module.exports = { usingNeon: useNeon, rawGet, rawSet, + rawDel, licenseDb, }; diff --git a/api/_lib/sync-db.js b/api/_lib/sync-db.js index f1c3179..fb59f31 100644 --- a/api/_lib/sync-db.js +++ b/api/_lib/sync-db.js @@ -91,6 +91,13 @@ async function ensureSchema() { updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `; + // Migrate sync_accounts created by older deploys — CREATE TABLE IF NOT EXISTS + // never alters an existing table, so newer columns must be added explicitly. + await db`ALTER TABLE pgstudio_sync.sync_accounts ADD COLUMN IF NOT EXISTS tier TEXT NOT NULL DEFAULT 'sponsor'`; + await db`ALTER TABLE pgstudio_sync.sync_accounts ADD COLUMN IF NOT EXISTS bytes_used BIGINT NOT NULL DEFAULT 0`; + await db`ALTER TABLE pgstudio_sync.sync_accounts ADD COLUMN IF NOT EXISTS item_count INT NOT NULL DEFAULT 0`; + await db`ALTER TABLE pgstudio_sync.sync_accounts ADD COLUMN IF NOT EXISTS inactive_since TIMESTAMPTZ`; + await db`ALTER TABLE pgstudio_sync.sync_accounts ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()`; await db` CREATE TABLE IF NOT EXISTS pgstudio_sync.sync_devices ( account_id TEXT NOT NULL, diff --git a/docs/CLOUD_SYNC.md b/docs/CLOUD_SYNC.md index 2fac4bb..9c8d800 100644 --- a/docs/CLOUD_SYNC.md +++ b/docs/CLOUD_SYNC.md @@ -106,6 +106,19 @@ Pick connections, saved queries, notebooks, and optionally passwords. A first sy | Pause / resume | **`NexQL Sync: Pause Sync`** | | Sign out | **`NexQL Sync: Sign Out of Sync`** — local data kept; clears local vault session | +### Deleting synced items + +Deleting a connection, saved query, or notebook on one device removes it everywhere — the same delete (tombstone) protocol is used by **every** backend, including NexQL Cloud and self-hosted Shared Postgres: + +1. The item is removed locally and a **delete** is queued in the pending changes list. +2. The next sync pushes a **tombstone** to the backend using the item's last-synced version as a compare-and-swap base, so a concurrent edit from another device can't be silently clobbered. +3. Once the backend acknowledges the tombstone, the item is dropped from the local sync index and the pending entry clears. +4. Other devices receive the tombstone on their next pull and remove their local copy. Tombstones are permanent — a deleted item is never resurrected by a later sync. + +**Open editors are handled safely.** Deleting a notebook whose tab is still open no longer re-uploads or "resurrects" it: a document whose backing file is gone is skipped during collection, so the delete propagates cleanly even with the tab open. Items that were never pushed to the backend are simply dropped from the queue (there is nothing remote to delete). + +> If you deleted notebooks in an older build and the cloud copy lingered, re-deleting them now pushes a proper tombstone. Alternatively, **Replace Cloud with Local** (from the Sync menu) force-pushes this device's state as the source of truth. + ### Settings Configure in **Settings → PostgreSQL Explorer → Sync**: diff --git a/src/activation/commandSpecs.ts b/src/activation/commandSpecs.ts index 389e46f..9054cb1 100644 --- a/src/activation/commandSpecs.ts +++ b/src/activation/commandSpecs.ts @@ -672,11 +672,13 @@ export function getCommandSpecs( await vscode.workspace.fs.delete(item.uri, { recursive: false }); if (syncId) { try { - const { SyncIndex } = await import('../features/sync/SyncIndex'); const { recordSyncActivity } = await import('../features/sync/SyncActivityLog'); - const index = new SyncIndex(context); - index.remove(syncId); - await index.flush(); + // Do NOT remove the index entry here. The sync engine emits a cloud + // tombstone by walking syncedIds() for items that were synced before + // and are now gone locally (buildOps delete branch). Removing it up + // front strips the compare-and-swap base, so the delete would never + // propagate to the cloud or other devices. recordAccepted() prunes + // the index once the tombstone is acknowledged. recordSyncActivity({ kind: 'notebook', action: 'delete', diff --git a/src/features/aiAssistant/AiModelCatalogService.ts b/src/features/aiAssistant/AiModelCatalogService.ts index f16398e..84ca786 100644 --- a/src/features/aiAssistant/AiModelCatalogService.ts +++ b/src/features/aiAssistant/AiModelCatalogService.ts @@ -12,8 +12,11 @@ import { listAnthropicModels, listCursorModels, listCustomModels, + listDeepSeekModels, listGeminiModels, listGitHubModels, + listMistralModels, + listMoonshotModels, listOpenAIModels, listVsCodeLanguageModels, } from './modelListing'; @@ -108,7 +111,7 @@ export class AiModelCatalogService { await this._appendProviderModels(catalog, 'opencode', async () => listOpencodeModels(config)); - for (const provider of ['openai', 'anthropic', 'gemini'] as DirectApiKeyProvider[]) { + for (const provider of ['openai', 'anthropic', 'gemini', 'deepseek', 'moonshot', 'mistral'] as DirectApiKeyProvider[]) { const apiKey = await this.credentials.getApiKey(provider); if (apiKey) { await this._appendProviderModels(catalog, provider, () => this._listForDirectProvider(provider, apiKey)); @@ -214,6 +217,12 @@ export class AiModelCatalogService { return listAnthropicModels(apiKey); case 'gemini': return listGeminiModels(apiKey); + case 'deepseek': + return listDeepSeekModels(apiKey); + case 'moonshot': + return listMoonshotModels(apiKey); + case 'mistral': + return listMistralModels(apiKey); case 'custom': return []; default: @@ -229,6 +238,12 @@ export class AiModelCatalogService { return 'claude-sonnet-4-20250514'; case 'gemini': return 'gemini-2.5-flash'; + case 'deepseek': + return 'deepseek-chat'; + case 'moonshot': + return 'moonshot-v1-8k'; + case 'mistral': + return 'mistral-large-latest'; case 'github': return 'openai/gpt-4.1'; case 'cursor': diff --git a/src/features/aiAssistant/aiConfig.ts b/src/features/aiAssistant/aiConfig.ts index c3fed9c..6591b2b 100644 --- a/src/features/aiAssistant/aiConfig.ts +++ b/src/features/aiAssistant/aiConfig.ts @@ -147,6 +147,12 @@ export function providerDisplayName(provider: AiProviderId): string { return 'Anthropic'; case 'gemini': return 'Gemini'; + case 'deepseek': + return 'DeepSeek'; + case 'moonshot': + return 'Moonshot / Kimi'; + case 'mistral': + return 'Mistral AI'; case 'custom': return 'Custom'; case 'ollama': diff --git a/src/features/aiAssistant/modelListing.ts b/src/features/aiAssistant/modelListing.ts index 321a25e..dd3ff45 100644 --- a/src/features/aiAssistant/modelListing.ts +++ b/src/features/aiAssistant/modelListing.ts @@ -187,6 +187,74 @@ export async function listCustomModels(endpoint: string, apiKey: string): Promis }); } +/** Shared helper: fetch GET /v1/models from any OpenAI-compatible host with Bearer auth. */ +function listOpenAiCompatibleModels( + hostname: string, + apiKey: string, + filter?: (id: string) => boolean, +): Promise { + return new Promise((resolve, reject) => { + const options = { + hostname, + path: '/v1/models', + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }; + + const req = https.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => { + if (res.statusCode === 200) { + try { + const data = JSON.parse(body); + let models: string[] = (data.data ?? []) + .map((m: { id: string }) => m.id) + .filter(Boolean); + if (filter) { + models = models.filter(filter); + } + resolve(models.sort()); + } catch { + reject(new Error('Failed to parse models response')); + } + } else { + reject(new Error(`Failed to list models: ${res.statusCode} - ${body}`)); + } + }); + }); + + req.on('error', (err) => reject(err)); + req.end(); + }); +} + +/** List DeepSeek chat models (platform.deepseek.com — OpenAI-compatible). */ +export function listDeepSeekModels(apiKey: string): Promise { + return listOpenAiCompatibleModels('api.deepseek.com', apiKey, (id) => + id.startsWith('deepseek-'), + ); +} + +/** List Moonshot / Kimi models (platform.moonshot.cn — OpenAI-compatible). */ +export function listMoonshotModels(apiKey: string): Promise { + return listOpenAiCompatibleModels('api.moonshot.cn', apiKey, (id) => + id.startsWith('moonshot-'), + ); +} + +/** List Mistral AI models (api.mistral.ai — OpenAI-compatible). */ +export function listMistralModels(apiKey: string): Promise { + // Mistral returns chat-capable models; exclude embedding-only ones + return listOpenAiCompatibleModels('api.mistral.ai', apiKey, (id) => + !id.includes('embed'), + ); +} + + export interface VsCodeLanguageModelEntry { id: string; displayName: string; diff --git a/src/features/aiAssistant/types.ts b/src/features/aiAssistant/types.ts index 56aea11..3af48e9 100644 --- a/src/features/aiAssistant/types.ts +++ b/src/features/aiAssistant/types.ts @@ -1,10 +1,20 @@ /** Providers that use a per-provider API key in secret storage. */ -export type DirectApiKeyProvider = 'openai' | 'anthropic' | 'gemini' | 'custom'; +export type DirectApiKeyProvider = + | 'openai' + | 'anthropic' + | 'gemini' + | 'deepseek' + | 'moonshot' + | 'mistral' + | 'custom'; export const DIRECT_API_KEY_PROVIDERS: readonly DirectApiKeyProvider[] = [ 'openai', 'anthropic', 'gemini', + 'deepseek', + 'moonshot', + 'mistral', 'custom', ] as const; diff --git a/src/features/settings/handlers/ai.ts b/src/features/settings/handlers/ai.ts index ef2a79a..a11a974 100644 --- a/src/features/settings/handlers/ai.ts +++ b/src/features/settings/handlers/ai.ts @@ -15,8 +15,11 @@ import { listAnthropicModels, listCursorModels, listCustomModels, + listDeepSeekModels, listGeminiModels, listGitHubModels, + listMistralModels, + listMoonshotModels, listOpenAIModels, listVsCodeLanguageModels, resolveVsCodeLanguageModel, @@ -26,7 +29,12 @@ import type { SettingsHubHostContext, SettingsHubMessage, SettingsSectionHandler export interface AiSettings { configScope?: AiConfigScope; - provider: string; + provider?: string; + scope?: string; + defaultNotebookProvider?: string; + defaultNotebookModel?: string; + defaultChatProvider?: string; + defaultChatModel?: string; apiKey?: string; apiKeys?: Partial>; cursorApiKey?: string; @@ -104,13 +112,20 @@ export class AiSectionHandler implements SettingsSectionHandler { const apiKeys = await credentials.getAllApiKeys(); const cursorApiKey = (await credentials.getCursorApiKey()) || ''; const githubSession = await getGitHubSession(); - const scoped = readAiScopeSettings(config, this.configScope); + + const notebookScope = readAiScopeSettings(config, 'notebook'); + const chatScope = readAiScopeSettings(config, 'chat'); + const lastModels = this.host.extensionContext.globalState.get>('postgresExplorer.ai.lastModelsByProvider') || {}; this.host.post({ type: 'ai/settings', settings: { configScope: this.configScope, - provider: scoped.provider, + notebookProvider: notebookScope.provider, + notebookModel: notebookScope.model, + chatProvider: chatScope.provider, + chatModel: chatScope.model, + lastModels, apiKeys, cursorApiKey, opencodeCliPath: config.get('opencodeCliPath', ''), @@ -120,7 +135,6 @@ export class AiSectionHandler implements SettingsSectionHandler { opencodeSkipPermissions: config.get('opencodeSkipPermissions', true), opencodeAutoApprovePermissions: config.get('opencodeAutoApprovePermissions', true), opencodeServePort: config.get('opencodeServePort', 0), - model: scoped.model, endpoint: config.get('aiEndpoint', ''), githubAuth: { connected: !!githubSession, @@ -132,16 +146,6 @@ export class AiSectionHandler implements SettingsSectionHandler { private async save(settings: AiSettings): Promise { try { - const scope: AiConfigScope = settings.configScope === 'chat' ? 'chat' : 'notebook'; - this.configScope = scope; - - await this.setScopedProvider( - scope, - settings.provider, - settings.model || '', - settings.endpoint || '', - ); - const credentials = AiCredentialsService.getInstance(this.host.extensionContext); if (settings.apiKeys) { await credentials.saveAllApiKeys(settings.apiKeys); @@ -187,11 +191,41 @@ export class AiSectionHandler implements SettingsSectionHandler { vscode.ConfigurationTarget.Global, ); - if (settings.model) { + // Save custom endpoint + await config.update( + 'aiEndpoint', + settings.endpoint || '', + vscode.ConfigurationTarget.Global, + ); + + // Save notebook scope defaults + if (settings.defaultNotebookProvider) { + await writeAiScopeSettings('notebook', { + provider: settings.defaultNotebookProvider as AiProviderId, + model: settings.defaultNotebookModel || '', + }); + // Also update legacy/fallback config keys + await config.update('aiProvider', settings.defaultNotebookProvider, vscode.ConfigurationTarget.Global); + await config.update('aiModel', settings.defaultNotebookModel || '', vscode.ConfigurationTarget.Global); + await rememberLastModelForProvider( this.host.extensionContext, - settings.provider as AiProviderId, - settings.model, + settings.defaultNotebookProvider as AiProviderId, + settings.defaultNotebookModel || '', + ); + } + + // Save chat scope defaults + if (settings.defaultChatProvider) { + await writeAiScopeSettings('chat', { + provider: settings.defaultChatProvider as AiProviderId, + model: settings.defaultChatModel || '', + }); + + await rememberLastModelForProvider( + this.host.extensionContext, + settings.defaultChatProvider as AiProviderId, + settings.defaultChatModel || '', ); } @@ -258,6 +292,39 @@ export class AiSectionHandler implements SettingsSectionHandler { throw new Error('API Key is required for Gemini'); } testResult = await testGemini(geminiKey, settings.model || 'gemini-2.5-flash'); + } else if (settings.provider === 'deepseek') { + const deepseekKey = directApiKeyFromSettings(settings, 'deepseek'); + if (!deepseekKey) { + throw new Error('API Key is required for DeepSeek'); + } + testResult = await testOpenAiCompatible( + 'api.deepseek.com', + deepseekKey, + settings.model || 'deepseek-chat', + 'DeepSeek', + ); + } else if (settings.provider === 'moonshot') { + const moonshotKey = directApiKeyFromSettings(settings, 'moonshot'); + if (!moonshotKey) { + throw new Error('API Key is required for Moonshot / Kimi'); + } + testResult = await testOpenAiCompatible( + 'api.moonshot.cn', + moonshotKey, + settings.model || 'moonshot-v1-8k', + 'Moonshot / Kimi', + ); + } else if (settings.provider === 'mistral') { + const mistralKey = directApiKeyFromSettings(settings, 'mistral'); + if (!mistralKey) { + throw new Error('API Key is required for Mistral AI'); + } + testResult = await testOpenAiCompatible( + 'api.mistral.ai', + mistralKey, + settings.model || 'mistral-large-latest', + 'Mistral AI', + ); } else if (settings.provider === 'custom') { if (!settings.endpoint) { throw new Error('Endpoint is required for custom provider'); @@ -312,6 +379,24 @@ export class AiSectionHandler implements SettingsSectionHandler { throw new Error('API Key is required to list models'); } models = await listGeminiModels(geminiKey); + } else if (settings.provider === 'deepseek') { + const deepseekKey = directApiKeyFromSettings(settings, 'deepseek'); + if (!deepseekKey) { + throw new Error('API Key is required to list DeepSeek models'); + } + models = await listDeepSeekModels(deepseekKey); + } else if (settings.provider === 'moonshot') { + const moonshotKey = directApiKeyFromSettings(settings, 'moonshot'); + if (!moonshotKey) { + throw new Error('API Key is required to list Moonshot models'); + } + models = await listMoonshotModels(moonshotKey); + } else if (settings.provider === 'mistral') { + const mistralKey = directApiKeyFromSettings(settings, 'mistral'); + if (!mistralKey) { + throw new Error('API Key is required to list Mistral models'); + } + models = await listMistralModels(mistralKey); } else if (settings.provider === 'custom') { const customKey = directApiKeyFromSettings(settings, 'custom'); if (settings.endpoint && customKey) { @@ -325,10 +410,17 @@ export class AiSectionHandler implements SettingsSectionHandler { models = await listCustomModels(settings.endpoint || DEFAULT_LMSTUDIO_ENDPOINT, ''); } - this.host.post({ type: 'ai/modelsListed', models }); + this.host.post({ + type: 'ai/modelsListed', + provider: settings.provider, + scope: settings.scope, + models, + }); } catch (err: unknown) { this.host.post({ type: 'ai/modelsListError', + provider: settings.provider, + scope: settings.scope, error: err instanceof Error ? err.message : String(err), }); } @@ -479,6 +571,52 @@ async function testCursor(apiKey: string, model: string): Promise { return `Cursor connection successful${user.userEmail ? ` for ${user.userEmail}` : ''}${model && model !== 'auto' ? `! Model: ${model}` : '!'}`; } +/** + * Generic test for any OpenAI-compatible provider (DeepSeek, Moonshot, Mistral, etc.). + * Sends a minimal chat completion request and reports success or failure. + */ +function testOpenAiCompatible( + hostname: string, + apiKey: string, + model: string, + providerLabel: string, +): Promise { + return new Promise((resolve, reject) => { + const data = JSON.stringify({ + model, + messages: [{ role: 'user', content: 'Hello' }], + max_tokens: 10, + }); + + const options = { + hostname, + path: '/v1/chat/completions', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'Content-Length': Buffer.byteLength(data), + }, + }; + + const req = https.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => { + if (res.statusCode === 200) { + resolve(`${providerLabel} connection successful! Model: ${model}`); + } else { + reject(new Error(`${providerLabel} API error: ${res.statusCode} - ${body}`)); + } + }); + }); + + req.on('error', (err) => reject(err)); + req.write(data); + req.end(); + }); +} + function testOpenAI(apiKey: string, model: string): Promise { return new Promise((resolve, reject) => { const data = JSON.stringify({ diff --git a/src/features/settings/handlers/sync.ts b/src/features/settings/handlers/sync.ts index 6359de5..3835e2a 100644 --- a/src/features/settings/handlers/sync.ts +++ b/src/features/settings/handlers/sync.ts @@ -262,6 +262,7 @@ export class SyncSectionHandler implements SettingsSectionHandler { void vscode.window.showInformationMessage(ok ? 'Local data replaced from cloud.' : 'Replace failed.'); this.sendState(); this.sendItems(); + this.sendPending(); } private async replaceRemote(): Promise { @@ -276,6 +277,8 @@ export class SyncSectionHandler implements SettingsSectionHandler { const ok = await SyncController.getInstance().replaceCloudWithLocal(); void vscode.window.showInformationMessage(ok ? 'Cloud replaced from local.' : 'Replace failed.'); this.sendState(); + this.sendItems(); + this.sendPending(); } private async rebuildIndex(): Promise { diff --git a/src/features/sync/NotebookSyncService.ts b/src/features/sync/NotebookSyncService.ts index 1c3d79a..dc0c01c 100644 --- a/src/features/sync/NotebookSyncService.ts +++ b/src/features/sync/NotebookSyncService.ts @@ -195,6 +195,13 @@ export class NotebookSyncService { if (doc.isUntitled || doc.uri.scheme !== 'file' || seenPaths.has(resolvedFsPath)) { continue; } + // Skip docs whose backing file was deleted on disk. VS Code keeps the + // editor open after a delete, so without this the open document would + // re-observe the item every sync — resurrecting the index entry and + // blocking the delete tombstone (the item never looks "gone locally"). + if (!fs.existsSync(doc.uri.fsPath)) { + continue; + } const metadata = doc.metadata as Record; let syncId = typeof metadata.syncId === 'string' ? metadata.syncId : undefined; diff --git a/src/features/sync/SyncActivityLog.ts b/src/features/sync/SyncActivityLog.ts index 6479557..c767e1a 100644 --- a/src/features/sync/SyncActivityLog.ts +++ b/src/features/sync/SyncActivityLog.ts @@ -119,6 +119,11 @@ export class SyncActivityLog { void this.context.globalState.update(SYNC_INBOUND_LOG_KEY, []); } + /** Flush the outbound queue only (keeps inbound history). */ + clearPending(): void { + this.save([]); + } + recordInbound(entry: Omit & { appliedAt?: number }): void { const log = this.loadInbound(); log.push({ diff --git a/src/features/sync/SyncController.ts b/src/features/sync/SyncController.ts index 1aaa1a9..3e4c99e 100644 --- a/src/features/sync/SyncController.ts +++ b/src/features/sync/SyncController.ts @@ -648,9 +648,19 @@ export class SyncController implements vscode.Disposable { const acked: string[] = []; for (const p of log.listPending()) { const entry = index.get(p.itemId); - if (p.action === 'delete' && !entry) { + if (p.action === 'delete') { + // Acked once the tombstone is pushed (entry removed by recordAccepted) + // or when the item never reached the cloud (no syncedVersion) — there + // is nothing left to delete remotely, so the queue entry is obsolete. + if (!entry || entry.syncedVersion == null) { + acked.push(`${p.kind}:${p.itemId}`); + } + } else if (!entry) { + // Orphan: the item id is no longer tracked (e.g. its syncId was + // regenerated). Nothing left to push — drop the stale pending entry so + // it stops showing as a phantom "Update" forever. acked.push(`${p.kind}:${p.itemId}`); - } else if (entry && entry.syncedHash && entry.syncedHash === entry.lastObservedHash) { + } else if (entry.syncedHash && entry.syncedHash === entry.lastObservedHash) { acked.push(`${p.kind}:${p.itemId}`); } } @@ -677,6 +687,9 @@ export class SyncController implements vscode.Disposable { await index.flush(); await this.setCursor(config, 0); await this.runLocked(config, { direction: 'pull' }); + // Local state was wiped and rebuilt from cloud — any queued outbound + // changes referenced the old local items and are now meaningless. + SyncActivityLog.getInstance(this.context).clearPending(); await this.context.globalState.update(SYNC_LAST_SYNC_AT_KEY, Date.now()); this.setStatus('synced'); return true; @@ -707,6 +720,9 @@ export class SyncController implements vscode.Disposable { await index.flush(); await this.setCursor(config, 0); await this.runLocked(config, { direction: 'push' }); + // Local is now the source of truth — everything was force-pushed, so no + // outbound change can still be pending. Flush the queue unconditionally. + SyncActivityLog.getInstance(this.context).clearPending(); await this.context.globalState.update(SYNC_LAST_SYNC_AT_KEY, Date.now()); this.setStatus('synced'); return true; diff --git a/templates/settings-hub/index.html b/templates/settings-hub/index.html index cf766e8..b331f30 100644 --- a/templates/settings-hub/index.html +++ b/templates/settings-hub/index.html @@ -140,318 +140,581 @@

No connections yet

AI Configuration

-

Provider for query assistance and chat features

+

Configure API keys for any provider — set your default model at the bottom

-
-
- - -

API keys are shared; provider and model are saved per scope.

-
+ +
+ + + + +
-