From 4d87465b097443f9a3a5ccce25867fcd795f53bf Mon Sep 17 00:00:00 2001 From: tammam-g Date: Thu, 17 Oct 2024 18:22:59 -0400 Subject: [PATCH 1/2] MVP working dataconnect:sql:shell (#7778) * MVP working dataconnect:sql:shell * Allow multiline SQL queries (copy paste not working however) * Fix linting issues * Update Changlog * Minor text changes * Move some interactive sql shell command to gcp/cloudsql/interactive.ts * Use colorette instead of chalk * Update CHANGELOG.md Co-authored-by: joehan --------- Co-authored-by: joehan --- CHANGELOG.md | 1 + src/commands/dataconnect-sql-shell.ts | 139 ++++++++++++++++++++++++++ src/commands/index.ts | 1 + src/dataconnect/schemaMigration.ts | 2 +- src/gcp/cloudsql/interactive.ts | 55 ++++++++++ 5 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 src/commands/dataconnect-sql-shell.ts create mode 100644 src/gcp/cloudsql/interactive.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..cf43f820411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Added new command `dataconnect:sql:shell` which run queries against Data Connect CloudSQL instances (#7778). diff --git a/src/commands/dataconnect-sql-shell.ts b/src/commands/dataconnect-sql-shell.ts new file mode 100644 index 00000000000..9055267d6a5 --- /dev/null +++ b/src/commands/dataconnect-sql-shell.ts @@ -0,0 +1,139 @@ +import * as pg from "pg"; +import * as clc from "colorette"; +import { Connector, IpAddressTypes, AuthTypes } from "@google-cloud/cloud-sql-connector"; + +import { Command } from "../command"; +import { Options } from "../options"; +import { needProjectId } from "../projectUtils"; +import { ensureApis } from "../dataconnect/ensureApis"; +import { requirePermissions } from "../requirePermissions"; +import { pickService } from "../dataconnect/fileUtils"; +import { getIdentifiers } from "../dataconnect/schemaMigration"; +import { requireAuth } from "../requireAuth"; +import { getIAMUser } from "../gcp/cloudsql/connect"; +import * as cloudSqlAdminClient from "../gcp/cloudsql/cloudsqladmin"; +import { prompt, Question } from "../prompt"; +import { logger } from "../logger"; +import { FirebaseError } from "../error"; +import { FBToolsAuthClient } from "../gcp/cloudsql/fbToolsAuthClient"; +import { confirmDangerousQuery, interactiveExecuteQuery } from "../gcp/cloudsql/interactive"; + +// Not a comprehensive list, used for keyword coloring. +const sqlKeywords = [ + "SELECT", + "FROM", + "WHERE", + "INSERT", + "UPDATE", + "DELETE", + "JOIN", + "GROUP", + "ORDER", + "LIMIT", + "GRANT", + "CREATE", + "DROP", +]; + +async function promptForQuery(): Promise { + let query = ""; + let line = ""; + + do { + const question: Question = { + type: "input", + name: "line", + message: query ? "> " : "Enter your SQL query (or '.exit'):", + transformer: (input: string) => { + // Highlight SQL keywords + return input + .split(" ") + .map((word) => (sqlKeywords.includes(word.toUpperCase()) ? clc.cyan(word) : word)) + .join(" "); + }, + }; + + ({ line } = await prompt({ nonInteractive: false }, [question])); + line = line.trimEnd(); + + if (line.toLowerCase() === ".exit") { + return ".exit"; + } + + query += (query ? "\n" : "") + line; + } while (line !== "" && !query.endsWith(";")); + return query; +} + +async function mainShellLoop(conn: pg.PoolClient) { + while (true) { + const query = await promptForQuery(); + if (query.toLowerCase() === ".exit") { + break; + } + + if (query === "") { + continue; + } + + if (await confirmDangerousQuery(query)) { + await interactiveExecuteQuery(query, conn); + } else { + logger.info(clc.yellow("Query cancelled.")); + } + } +} + +export const command = new Command("dataconnect:sql:shell [serviceId]") + .description("Starts a shell connected directly to your dataconnect cloudsql instance.") + .before(requirePermissions, ["firebasedataconnect.services.list", "cloudsql.instances.connect"]) + .before(requireAuth) + .action(async (serviceId: string, options: Options) => { + const projectId = needProjectId(options); + await ensureApis(projectId); + const serviceInfo = await pickService(projectId, options.config, serviceId); + const { instanceId, databaseId } = getIdentifiers(serviceInfo.schema); + const { user: username } = await getIAMUser(options); + const instance = await cloudSqlAdminClient.getInstance(projectId, instanceId); + + // Setup the connection + const connectionName = instance.connectionName; + if (!connectionName) { + throw new FirebaseError( + `Could not get instance connection string for ${options.instanceId}:${options.databaseId}`, + ); + } + const connector: Connector = new Connector({ + auth: new FBToolsAuthClient(), + }); + const clientOpts = await connector.getOptions({ + instanceConnectionName: connectionName, + ipType: IpAddressTypes.PUBLIC, + authType: AuthTypes.IAM, + }); + const pool: pg.Pool = new pg.Pool({ + ...clientOpts, + user: username, + database: databaseId, + }); + const conn: pg.PoolClient = await pool.connect(); + + logger.info(`Logged in as ${username}`); + logger.info(clc.cyan("Welcome to Data Connect Cloud SQL Shell")); + logger.info( + clc.gray( + "Type your your SQL query or '.exit' to quit, queries should end with ';' or add empty line to execute.", + ), + ); + + // Start accepting queries + await mainShellLoop(conn); + + // Cleanup after exit + logger.info(clc.yellow("Exiting shell...")); + conn.release(); + await pool.end(); + connector.close(); + + return { projectId, serviceId }; + }); diff --git a/src/commands/index.ts b/src/commands/index.ts index d68e843adcb..f86358504c2 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -217,6 +217,7 @@ export function load(client: any): any { client.dataconnect.sql.diff = loadCommand("dataconnect-sql-diff"); client.dataconnect.sql.migrate = loadCommand("dataconnect-sql-migrate"); client.dataconnect.sql.grant = loadCommand("dataconnect-sql-grant"); + client.dataconnect.sql.shell = loadCommand("dataconnect-sql-shell"); client.dataconnect.sdk = {}; client.dataconnect.sdk.generate = loadCommand("dataconnect-sdk-generate"); client.target = loadCommand("target"); diff --git a/src/dataconnect/schemaMigration.ts b/src/dataconnect/schemaMigration.ts index a19b1019ae7..e26df809eed 100644 --- a/src/dataconnect/schemaMigration.ts +++ b/src/dataconnect/schemaMigration.ts @@ -282,7 +282,7 @@ function setSchemaValidationMode(schema: Schema, schemaValidation: SchemaValidat } } -function getIdentifiers(schema: Schema): { +export function getIdentifiers(schema: Schema): { instanceName: string; instanceId: string; databaseId: string; diff --git a/src/gcp/cloudsql/interactive.ts b/src/gcp/cloudsql/interactive.ts new file mode 100644 index 00000000000..17a872c614e --- /dev/null +++ b/src/gcp/cloudsql/interactive.ts @@ -0,0 +1,55 @@ +import * as pg from "pg"; +import * as ora from "ora"; +import * as clc from "colorette"; +import { logger } from "../../logger"; +import { confirm } from "../../prompt"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const Table = require("cli-table"); + +// Not comprehensive list, used for best offer prompting. +const destructiveSqlKeywords = ["DROP", "DELETE"]; + +function checkIsDestructiveSql(query: string): boolean { + const upperCaseQuery = query.toUpperCase(); + return destructiveSqlKeywords.some((keyword) => upperCaseQuery.includes(keyword.toUpperCase())); +} + +export async function confirmDangerousQuery(query: string): Promise { + if (checkIsDestructiveSql(query)) { + return await confirm({ + message: clc.yellow("This query may be destructive. Are you sure you want to proceed?"), + default: false, + }); + } + return true; +} + +// Pretty query execution display such as spinner and actual returned content for `SELECT` query. +export async function interactiveExecuteQuery(query: string, conn: pg.PoolClient) { + const spinner = ora("Executing query...").start(); + try { + const results = await conn.query(query); + spinner.succeed(clc.green("Query executed successfully")); + + if (Array.isArray(results.rows) && results.rows.length > 0) { + const table: any[] = new Table({ + head: Object.keys(results.rows[0]).map((key) => clc.cyan(key)), + style: { head: [], border: [] }, + }); + + for (const row of results.rows) { + table.push(Object.values(row) as any); + } + + logger.info(table.toString()); + } else { + // If nothing is returned and the query was select, let the user know there was no results. + if (query.toUpperCase().includes("SELECT")) { + logger.info(clc.yellow("No results returned")); + } + } + } catch (err) { + spinner.fail(clc.red(`Failed executing query: ${err}`)); + } +} From 4a2819d2d62544eb3857e872d5e2ab33e90f9667 Mon Sep 17 00:00:00 2001 From: Remi Rousselet Date: Mon, 21 Oct 2024 19:48:40 +0200 Subject: [PATCH 2/2] Report errors in configs, and load configs from anywhere (#7650) * Validate .yaml files and add "Open folder" button * Fix dataconnect error * Use RC file as source of truth for the current project ID. This avoids the extension overwriting the selected project if the RC file is manually edited. * Refactor subscriptions * Lod nested configs * Onboarding changes * Revert changes extracted into separate PRs * Make TS happy * Update config logic to show dropdown when 2+ firebase.json are detected * Remove logs * Fix if * Use effect cleanup * Add doc to follow * Use effect cleanup again * Remove `if` --------- Co-authored-by: Harold Shen --- firebase-vscode/common/messaging/protocol.ts | 10 + firebase-vscode/src/core/config.ts | 304 ++++++++++++------ firebase-vscode/src/core/index.ts | 2 +- .../src/data-connect/code-lens-provider.ts | 2 +- firebase-vscode/src/data-connect/config.ts | 242 +++++++++----- .../src/data-connect/diagnostics.ts | 46 +++ firebase-vscode/src/data-connect/index.ts | 20 +- firebase-vscode/src/result.ts | 103 ++++-- .../src/test/suite/src/core/config.test.ts | 124 +++---- .../test/suite/src/dataconnect/config.test.ts | 12 +- firebase-vscode/src/test/utils/mock.ts | 13 + firebase-vscode/webviews/SidebarApp.tsx | 34 +- 12 files changed, 600 insertions(+), 312 deletions(-) create mode 100644 firebase-vscode/src/data-connect/diagnostics.ts diff --git a/firebase-vscode/common/messaging/protocol.ts b/firebase-vscode/common/messaging/protocol.ts index 57cd75f0914..68b5edbff54 100644 --- a/firebase-vscode/common/messaging/protocol.ts +++ b/firebase-vscode/common/messaging/protocol.ts @@ -29,6 +29,7 @@ export interface WebviewToExtensionParamsMap { */ getInitialData: {}; getInitialHasFdcConfigs: void; + getInitialFirebaseConfigList: void; addUser: {}; logout: { email: string }; @@ -43,6 +44,9 @@ export interface WebviewToExtensionParamsMap { /** Trigger project selection */ selectProject: {}; + /** When 2+ firebase.json are detected, the user can manually pick one */ + selectFirebaseConfig: string; + /** * Prompt user for text input */ @@ -123,6 +127,12 @@ export interface ExtensionToWebviewParamsMap { infos?: RunningEmulatorInfo | undefined; }; + /** Lists all firebase.json in the workspace */ + notifyFirebaseConfigListChanged: { + values: string[]; + selected: string | undefined; + }; + notifyEmulatorsHanging: boolean; /** Triggered when new environment variables values are found. */ diff --git a/firebase-vscode/src/core/config.ts b/firebase-vscode/src/core/config.ts index 2c5d917417b..0562b96ee4c 100644 --- a/firebase-vscode/src/core/config.ts +++ b/firebase-vscode/src/core/config.ts @@ -8,15 +8,20 @@ import { RC, RCData } from "../../../src/rc"; import { Config } from "../../../src/config"; import { globalSignal } from "../utils/globals"; import { workspace } from "../utils/test_hooks"; -import { ResolvedDataConnectConfigs } from "../data-connect/config"; import { ValueOrError } from "../../common/messaging/protocol"; import { firstWhereDefined, onChange } from "../utils/signal"; import { Result, ResultError, ResultValue } from "../result"; import { FirebaseConfig } from "../firebaseConfig"; -import { effect } from "@preact/signals-react"; +import { computed, effect } from "@preact/signals-react"; + +const allFirebaseConfigsUris = globalSignal>([]); + +const selectedFirebaseConfigUri = globalSignal( + undefined, +); /** - * The .firebaserc configs. + * The firebase.json configs. * * `undefined` means that the extension has yet to load the file. * {@link ResultValue} with an `undefined` value means that the file was not found. @@ -25,16 +30,22 @@ import { effect } from "@preact/signals-react"; * This enables the UI to differentiate between "no config" and "error reading config", * and also await for configs to be loaded (thanks to the {@link firstWhereDefined} util) */ -export const firebaseRC = globalSignal | undefined>( - undefined, -); - -export const dataConnectConfigs = globalSignal< - ResolvedDataConnectConfigs | undefined +export const firebaseConfig = globalSignal< + Result | undefined >(undefined); +const selectedRCUri = computed(() => { + const configUri = selectedFirebaseConfigUri.value; + if (!configUri) { + return undefined; + } + + const folderPath = path.dirname(configUri.fsPath); + return vscode.Uri.file(path.join(folderPath, ".firebaserc")); +}); + /** - * The firebase.json configs. + * The .firebaserc configs. * * `undefined` means that the extension has yet to load the file. * {@link ResultValue} with an `undefined` value means that the file was not found. @@ -43,9 +54,9 @@ export const dataConnectConfigs = globalSignal< * This enables the UI to differentiate between "no config" and "error reading config", * and also await for configs to be loaded (thanks to the {@link firstWhereDefined} util) */ -export const firebaseConfig = globalSignal< - Result | undefined ->(undefined); +export const firebaseRC = globalSignal | undefined>( + undefined, +); /** * Write new default project to .firebaserc @@ -64,24 +75,17 @@ export async function updateFirebaseRCProject(values: { // This is only for the sake of calling `save()`. new RC(path.join(currentOptions.value.cwd, ".firebaserc"), {}); - if (values.projectAlias) { - if ( - rc.resolveAlias(values.projectAlias.alias) === - values.projectAlias.projectId - ) { - // Nothing to update, avoid an unnecessary write. - // That's especially important as a write will trigger file watchers, - // which may then re-trigger this function. - return; - } - + if ( + values.projectAlias && + rc.resolveAlias(values.projectAlias.alias) !== values.projectAlias.projectId + ) { rc.addProjectAlias( values.projectAlias.alias, values.projectAlias.projectId, ); - } - rc.save(); + rc.save(); + } } function notifyFirebaseConfig(broker: ExtensionBrokerImpl) { @@ -104,112 +108,197 @@ function notifyFirebaseConfig(broker: ExtensionBrokerImpl) { }); } -function registerRc(broker: ExtensionBrokerImpl): Disposable { - firebaseRC.value = _readRC(); - const rcRemoveListener = onChange(firebaseRC, () => - notifyFirebaseConfig(broker), - ); +function displayStringForUri(uri: vscode.Uri) { + return vscode.workspace.asRelativePath(uri); +} - const showToastOnError = effect(() => { - const rc = firebaseRC.value; - if (rc instanceof ResultError) { - vscode.window.showErrorMessage(`Error reading .firebaserc:\n${rc.error}`); - } +function notifyFirebaseConfigListChanged(broker: ExtensionBrokerImpl) { + broker.send("notifyFirebaseConfigListChanged", { + values: allFirebaseConfigsUris.value.map(displayStringForUri), + selected: selectedFirebaseConfigUri.value + ? displayStringForUri(selectedFirebaseConfigUri.value) + : undefined, }); +} - const rcWatcher = _createWatcher(".firebaserc"); - rcWatcher?.onDidChange(() => (firebaseRC.value = _readRC())); - rcWatcher?.onDidCreate(() => (firebaseRC.value = _readRC())); - // TODO handle deletion of .firebaserc/.firebase.json/firemat.yaml - rcWatcher?.onDidDelete(() => (firebaseRC.value = undefined)); +async function registerRc( + context: vscode.ExtensionContext, + broker: ExtensionBrokerImpl, +) { + context.subscriptions.push({ + dispose: effect(() => { + firebaseRC.value = undefined; - return Disposable.from( - { dispose: rcRemoveListener }, - { dispose: showToastOnError }, - { dispose: () => rcWatcher?.dispose() }, - ); -} + const rcUri = selectedRCUri.value; + if (!rcUri) { + return; + } -function registerFirebaseConfig(broker: ExtensionBrokerImpl): Disposable { - firebaseConfig.value = _readFirebaseConfig(); + const watcher = workspace.value.createFileSystemWatcher(rcUri.fsPath); - const firebaseConfigRemoveListener = onChange(firebaseConfig, () => - notifyFirebaseConfig(broker), - ); + watcher.onDidChange(() => (firebaseRC.value = _readRC(rcUri))); + watcher.onDidCreate(() => (firebaseRC.value = _readRC(rcUri))); + // TODO handle deletion of .firebaserc/.firebase.json/firemat.yaml + watcher.onDidDelete(() => (firebaseRC.value = undefined)); + + firebaseRC.value = _readRC(rcUri); - const showToastOnError = effect(() => { - const config = firebaseConfig.value; - if (config instanceof ResultError) { - vscode.window.showErrorMessage( - `Error reading firebase.json:\n${config.error}`, + return () => { + watcher.dispose(); + }; + }), + }); + + context.subscriptions.push({ + dispose: onChange(firebaseRC, () => notifyFirebaseConfig(broker)), + }); + + context.subscriptions.push({ + dispose: effect(() => { + const rc = firebaseRC.value; + if (rc instanceof ResultError) { + vscode.window.showErrorMessage( + `Error reading .firebaserc:\n${rc.error}`, + ); + } + }), + }); +} + +async function registerFirebaseConfig( + context: vscode.ExtensionContext, + broker: ExtensionBrokerImpl, +) { + const firebaseJsonPattern = "**/firebase.json"; + allFirebaseConfigsUris.value = await findFiles(firebaseJsonPattern); + + const configWatcher = await _createWatcher(firebaseJsonPattern); + // Track the URI of any firebase.json in the project. + if (configWatcher) { + context.subscriptions.push(configWatcher); + + // We don't listen to changes here, as we'll only watch the selected config. + configWatcher.onDidCreate((addedUri) => { + allFirebaseConfigsUris.value = [ + ...allFirebaseConfigsUris.value, + addedUri, + ]; + }); + configWatcher.onDidDelete((deletedUri) => { + allFirebaseConfigsUris.value = allFirebaseConfigsUris.value.filter( + (uri) => uri.fsPath !== deletedUri.fsPath, ); - } + }); + } + + context.subscriptions.push({ + dispose: onChange(firebaseConfig, () => notifyFirebaseConfig(broker)), }); - const configWatcher = _createWatcher("firebase.json"); - configWatcher?.onDidChange( - () => (firebaseConfig.value = _readFirebaseConfig()), - ); - configWatcher?.onDidCreate( - () => (firebaseConfig.value = _readFirebaseConfig()), - ); - configWatcher?.onDidDelete(() => (firebaseConfig.value = undefined)); + // When no config is selected, or the selected config is deleted, select the first one. + context.subscriptions.push({ + dispose: effect(() => { + const configUri = selectedFirebaseConfigUri.value; + // We watch all config URIs before selecting one, so that when deleting the selected + // config, the effect runs again and selects a new one. + const allConfigUris = allFirebaseConfigsUris.value; + if (configUri && fs.existsSync(configUri.fsPath)) { + return; + } + + if (allConfigUris[0] !== selectedFirebaseConfigUri.value) { + selectedFirebaseConfigUri.value = allConfigUris[0]; + } + }), + }); - return Disposable.from( - { dispose: firebaseConfigRemoveListener }, - { dispose: showToastOnError }, - { dispose: () => configWatcher?.dispose() }, - ); + let disposable: Disposable | undefined; + context.subscriptions.push({ dispose: () => disposable?.dispose() }); + context.subscriptions.push({ + dispose: effect(() => { + disposable?.dispose(); + disposable = undefined; + firebaseRC.value = undefined; + + const configUri = selectedFirebaseConfigUri.value; + if (!configUri) { + return; + } + + disposable = configWatcher?.onDidChange((uri) => { + // ignore changes from firebase.json files that are not the selected one + if (uri.fsPath !== configUri.fsPath) { + firebaseConfig.value = _readFirebaseConfig(configUri); + } + }); + + firebaseConfig.value = _readFirebaseConfig(configUri); + }), + }); + + // Bind the list of URIs to webviews + context.subscriptions.push({ + dispose: effect(() => { + // Listen to changes + allFirebaseConfigsUris.value; + selectedFirebaseConfigUri.value; + + notifyFirebaseConfigListChanged(broker); + }), + }); + context.subscriptions.push({ + dispose: broker.on("getInitialFirebaseConfigList", () => { + notifyFirebaseConfigListChanged(broker); + }), + }); + context.subscriptions.push({ + dispose: broker.on("selectFirebaseConfig", (uri) => { + selectedFirebaseConfigUri.value = allFirebaseConfigsUris.value.find( + (u) => displayStringForUri(u) === uri, + ); + }), + }); } -export function registerConfig(broker: ExtensionBrokerImpl): Disposable { +export async function registerConfig( + context: vscode.ExtensionContext, + broker: ExtensionBrokerImpl, +) { // On getInitialData, forcibly notifies the extension. - const getInitialDataRemoveListener = broker.on("getInitialData", () => { - notifyFirebaseConfig(broker); + context.subscriptions.push({ + dispose: broker.on("getInitialData", () => { + notifyFirebaseConfig(broker); + }), }); - // TODO handle deletion of .firebaserc/.firebase.json/firemat.yaml - - return Disposable.from( - { dispose: getInitialDataRemoveListener }, - registerFirebaseConfig(broker), - registerRc(broker), - ); + // Register configs before RC as the path to RC depends on the path to configs. + await registerFirebaseConfig(context, broker); + await registerRc(context, broker); } /** @internal */ -export function _readRC(): Result { +export function _readRC(uri: vscode.Uri): Result { return Result.guard(() => { - const configPath = getConfigPath(); - if (!configPath) { - return undefined; - } // RC.loadFile silences errors and returns a non-empty object if the rc file is // missing. Let's load it ourselves. - const rcPath = path.join(configPath, ".firebaserc"); - - if (!fs.existsSync(rcPath)) { + if (!fs.existsSync(uri.fsPath)) { return undefined; } - const json = fs.readFileSync(rcPath); + const json = fs.readFileSync(uri.fsPath); const data = JSON.parse(json.toString()); - return new RC(rcPath, data); + return new RC(uri.fsPath, data); }); } /** @internal */ -export function _readFirebaseConfig(): Result | undefined { +export function _readFirebaseConfig( + uri: vscode.Uri, +): Result | undefined { const result = Result.guard(() => { - const configPath = getConfigPath(); - if (!configPath) { - return undefined; - } - const config = Config.load({ - configPath: path.join(configPath, "firebase.json"), - }); + const config = Config.load({ configPath: uri.fsPath }); if (!config) { // Config.load may return null. We transform it to undefined. return undefined; @@ -226,17 +315,22 @@ export function _readFirebaseConfig(): Result | undefined { } /** @internal */ -export function _createWatcher(file: string): FileSystemWatcher | undefined { - if (!currentOptions.value.cwd) { - return undefined; - } +export async function _createWatcher( + file: string, +): Promise { + const cwdSignal = computed(() => currentOptions.value.cwd); + const cwd = await firstWhereDefined(cwdSignal); - return workspace.value?.createFileSystemWatcher( + return workspace.value.createFileSystemWatcher( // Using RelativePattern enables tests to use watchers too. - new vscode.RelativePattern(vscode.Uri.file(currentOptions.value.cwd), file), + new vscode.RelativePattern(vscode.Uri.file(cwd), file), ); } +async function findFiles(file: string) { + return workspace.value.findFiles(file, "**/node_modules"); +} + export function getRootFolders() { const ws = workspace.value; if (!ws) { diff --git a/firebase-vscode/src/core/index.ts b/firebase-vscode/src/core/index.ts index 83fe2af0d81..5c2b9e37837 100644 --- a/firebase-vscode/src/core/index.ts +++ b/firebase-vscode/src/core/index.ts @@ -80,6 +80,7 @@ export async function registerCore( }, ); + registerConfig(context, broker); const refreshCmd = vscode.commands.registerCommand( "firebase.refresh", async () => { @@ -97,7 +98,6 @@ export async function registerCore( refreshCmd, emulatorsController, registerOptions(context), - registerConfig(broker), registerEnv(broker), registerUser(broker, telemetryLogger), registerProject(broker), diff --git a/firebase-vscode/src/data-connect/code-lens-provider.ts b/firebase-vscode/src/data-connect/code-lens-provider.ts index bec28492022..331cab7affc 100644 --- a/firebase-vscode/src/data-connect/code-lens-provider.ts +++ b/firebase-vscode/src/data-connect/code-lens-provider.ts @@ -208,7 +208,7 @@ export class ConfigureSdkCodeLensProvider extends ComputedCodeLensProvider { title: `$(tools) Configure Generated SDK`, command: "fdc.connector.configure-sdk", tooltip: "Configure a generated SDK for this connector", - arguments: [connectorConfig!.tryReadValue], + arguments: [connectorConfig], }), ); } diff --git a/firebase-vscode/src/data-connect/config.ts b/firebase-vscode/src/data-connect/config.ts index d6710642556..4ed2b711c6b 100644 --- a/firebase-vscode/src/data-connect/config.ts +++ b/firebase-vscode/src/data-connect/config.ts @@ -19,112 +19,206 @@ import { Config } from "../config"; import { DataConnectMultiple } from "../firebaseConfig"; import path from "path"; import { ExtensionBrokerImpl } from "../extension-broker"; +import * as fs from "fs"; export * from "../core/config"; +export type DataConnectConfigsValue = ResolvedDataConnectConfigs | undefined; +export type DataConnectConfigsError = { + path?: string; + error: Error | unknown; + range: vscode.Range; +}; + export const dataConnectConfigs = signal< - Result | undefined + | Result + | undefined >(undefined); -export function registerDataConnectConfigs( - broker: ExtensionBrokerImpl, -): vscode.Disposable { - let cancel: (() => void) | undefined; +export class ErrorWithPath extends Error { + constructor( + readonly path: string, + readonly error: unknown, + readonly range: vscode.Range, + ) { + super(error instanceof Error ? error.message : `${error}`); + } +} +export async function registerDataConnectConfigs( + context: vscode.ExtensionContext, + broker: ExtensionBrokerImpl, +) { function handleResult( firebaseConfig: Result | undefined, - ) { - cancel?.(); - cancel = undefined; - + ): undefined | (() => void) { // While waiting for the promise to resolve, we clear the configs, to tell anything that depends // on it that it's loading. dataConnectConfigs.value = undefined; - const configs = firebaseConfig?.followAsync( - async (config) => - new ResultValue( - await _readDataConnectConfigs(readFdcFirebaseJson(config)), - ), + const configs = firebaseConfig?.followAsync< + ResolvedDataConnectConfigs | undefined, + DataConnectConfigsError + >( + async (config) => { + const configs = await _readDataConnectConfigs( + readFdcFirebaseJson(config), + ); + + return new ResultValue< + ResolvedDataConnectConfigs | undefined, + DataConnectConfigsError + >(configs.requireValue); + }, + (err) => { + if (err instanceof ErrorWithPath) { + return { path: err.path, error: err.error, range: err.range }; + } + return { + path: undefined, + error: err, + range: new vscode.Range(0, 0, 0, 0), + }; + }, ); - cancel = + const operation = configs && - promise.cancelableThen( - configs, - (configs) => (dataConnectConfigs.value = configs.tryReadValue), - ).cancel; + promise.cancelableThen(configs, (configs) => { + return (dataConnectConfigs.value = configs); + }); + + return operation?.cancel; } - const sub = effect(() => handleResult(firebaseConfig.value)); + context.subscriptions.push({ + dispose: effect(() => handleResult(firebaseConfig.value)), + }); + + const dataConnectWatcher = await createWatcher( + "**/{dataconnect,connector}.yaml", + ); + if (dataConnectWatcher) { + context.subscriptions.push(dataConnectWatcher); - const dataConnectWatcher = createWatcher("**/{dataconnect,connector}.yaml"); - dataConnectWatcher?.onDidChange(() => handleResult(firebaseConfig.value)); - dataConnectWatcher?.onDidCreate(() => handleResult(firebaseConfig.value)); - dataConnectWatcher?.onDidDelete(() => handleResult(undefined)); - // TODO watch connectors + dataConnectWatcher.onDidChange(() => handleResult(firebaseConfig.value)); + dataConnectWatcher.onDidCreate(() => handleResult(firebaseConfig.value)); + dataConnectWatcher.onDidDelete(() => handleResult(firebaseConfig.value)); + } const hasConfigs = computed( () => !!dataConnectConfigs.value?.tryReadValue?.values.length, ); - const hasConfigSub = effect(() => { - broker.send("notifyHasFdcConfigs", hasConfigs.value); - }); - const getInitialHasFdcConfigsSub = broker.on( - "getInitialHasFdcConfigs", - () => { + context.subscriptions.push({ + dispose: effect(() => { broker.send("notifyHasFdcConfigs", hasConfigs.value); - }, - ); + }), + }); - return vscode.Disposable.from( - { dispose: sub }, - { dispose: hasConfigSub }, - { dispose: getInitialHasFdcConfigsSub }, - { dispose: () => cancel?.() }, - { dispose: () => { - dataConnectWatcher?.dispose(); - }}, - ); + context.subscriptions.push({ + dispose: broker.on("getInitialHasFdcConfigs", () => { + broker.send("notifyHasFdcConfigs", hasConfigs.value); + }), + }); } /** @internal */ export async function _readDataConnectConfigs( fdcConfig: DataConnectMultiple, ): Promise> { - return Result.guard(async () => { - const dataConnects = await Promise.all( - fdcConfig.map>(async (dataConnect) => { - // Paths may be relative to the firebase.json file. - const relativeConfigPath = getConfigPath() ?? ""; - const absoluteLocation = asAbsolutePath( - dataConnect.source, - relativeConfigPath, + async function mapConnector(connectorDirPath: string) { + const connectorYaml = await readConnectorYaml(connectorDirPath).catch( + (err: unknown) => { + const connectorPath = path.normalize( + path.join(connectorDirPath, "connector.yaml"), ); - const dataConnectYaml = await readDataConnectYaml(absoluteLocation); - const resolvedConnectors = await Promise.all( - dataConnectYaml.connectorDirs.map((connectorDir) => - Result.guard(async () => { - const connectorYaml = await readConnectorYaml( - // Paths may be relative to the dataconnect.yaml - asAbsolutePath(connectorDir, absoluteLocation), - ); - return new ResolvedConnectorYaml( - asAbsolutePath(connectorDir, absoluteLocation), - connectorYaml, - ); - }), - ), + throw new ErrorWithPath( + connectorPath, + err, + new vscode.Range(0, 0, 0, 0), ); - return new ResolvedDataConnectConfig( + }, + ); + + return new ResolvedConnectorYaml(connectorDirPath, connectorYaml); + } + + async function mapDataConnect(absoluteLocation: string) { + const dataConnectYaml = await readDataConnectYaml(absoluteLocation); + const connectorDirs = dataConnectYaml.connectorDirs; + if (!Array.isArray(connectorDirs)) { + throw new ErrorWithPath( + path.join(absoluteLocation, "dataconnect.yaml"), + `Expected 'connectorDirs' to be an array, but got ${connectorDirs}`, + // TODO(rrousselGit): Decode Yaml using AST to have the error message point to the `connectorDirs:` line + new vscode.Range(0, 0, 0, 0), + ); + } + + const resolvedConnectors = await Promise.all( + connectorDirs.map((relativeConnector) => { + const absoluteConnector = asAbsolutePath( + relativeConnector, absoluteLocation, - dataConnectYaml, - resolvedConnectors, - dataConnectYaml.location, ); + const connectorPath = path.join(absoluteConnector, "connector.yaml"); + try { + // Check if the file exists + if (!fs.existsSync(connectorPath)) { + throw new ErrorWithPath( + path.join(absoluteLocation, "dataconnect.yaml"), + `No connector.yaml found at ${relativeConnector}`, + // TODO(rrousselGit): Decode Yaml using AST to have the error message point to the `connectorDirs:` line + new vscode.Range(0, 0, 0, 0), + ); + } + + return mapConnector(absoluteConnector); + } catch (error) { + if (error instanceof ErrorWithPath) { + throw error; + } + + throw new ErrorWithPath( + connectorPath, + error, + new vscode.Range(0, 0, 0, 0), + ); + } }), ); + + return new ResolvedDataConnectConfig( + absoluteLocation, + dataConnectYaml, + resolvedConnectors, + dataConnectYaml.location, + ); + } + + return Result.guard(async () => { + const dataConnects = await Promise.all( + fdcConfig + // Paths may be relative to the firebase.json file. + .map((relative) => asAbsolutePath(relative.source, getConfigPath()!)) + .map(async (absolutePath) => { + try { + return await mapDataConnect(absolutePath); + } catch (error) { + if (error instanceof ErrorWithPath) { + throw error; + } + + throw new ErrorWithPath( + path.join(absolutePath, "dataconnect.yaml"), + error, + new vscode.Range(0, 0, 0, 0), + ); + } + }), + ); + return new ResolvedDataConnectConfigs(dataConnects); }); } @@ -148,7 +242,7 @@ export class ResolvedDataConnectConfig { constructor( readonly path: string, readonly value: DeepReadOnly, - readonly resolvedConnectors: Result[], + readonly resolvedConnectors: ResolvedConnectorYaml[], readonly dataConnectLocation: string, ) {} @@ -156,7 +250,7 @@ export class ResolvedDataConnectConfig { const result: string[] = []; for (const connector of this.resolvedConnectors) { - const id = connector.tryReadValue?.value.connectorId; + const id = connector.value.connectorId; if (id) { result.push(id); } @@ -187,10 +281,10 @@ export class ResolvedDataConnectConfig { ); } - findConnectorById(connectorId: string): ResolvedConnectorYaml { + findConnectorById(connectorId: string): ResolvedConnectorYaml | undefined { return this.resolvedConnectors.find( - (connector) => connector.tryReadValue!.value.connectorId === connectorId, - )!.tryReadValue!; + (connector) => connector.value.connectorId === connectorId, + ); } containsPath(path: string) { @@ -199,7 +293,7 @@ export class ResolvedDataConnectConfig { findEnclosingConnectorForPath(filePath: string) { return this.resolvedConnectors.find( - (connector) => connector.tryReadValue?.containsPath(filePath) ?? false, + (connector) => connector?.containsPath(filePath) ?? false, ); } } diff --git a/firebase-vscode/src/data-connect/diagnostics.ts b/firebase-vscode/src/data-connect/diagnostics.ts new file mode 100644 index 00000000000..6f10038a40d --- /dev/null +++ b/firebase-vscode/src/data-connect/diagnostics.ts @@ -0,0 +1,46 @@ +import { effect, Signal } from "@preact/signals-core"; +import * as vscode from "vscode"; +import { Result } from "../result"; +import { + DataConnectConfigsError, + DataConnectConfigsValue, + ErrorWithPath, +} from "./config"; + +export function registerDiagnostics( + context: vscode.ExtensionContext, + dataConnectConfigs: Signal< + | Result + | undefined + >, +) { + const collection = + vscode.languages.createDiagnosticCollection("data-connect"); + context.subscriptions.push(collection); + + context.subscriptions.push({ + dispose: effect(() => { + collection.clear(); + + const fdcConfigsValue = dataConnectConfigs.value; + fdcConfigsValue?.switchCase( + (_) => { + // Value. No-op as we're only dealing with errors here + }, + (fdcError) => { + const error = fdcError.error; + + collection.set(vscode.Uri.file(fdcError.path!), [ + new vscode.Diagnostic( + error instanceof ErrorWithPath + ? error.range + : new vscode.Range(0, 0, 0, 0), + error instanceof Error ? error.message : `${error}`, + vscode.DiagnosticSeverity.Error, + ), + ]); + }, + ); + }), + }); +} diff --git a/firebase-vscode/src/data-connect/index.ts b/firebase-vscode/src/data-connect/index.ts index a2412eba8e0..50c4f6fa89f 100644 --- a/firebase-vscode/src/data-connect/index.ts +++ b/firebase-vscode/src/data-connect/index.ts @@ -31,6 +31,7 @@ import { registerWebview } from "../webview"; import { DataConnectToolkit } from "./toolkit"; import { registerFdcSdkGeneration } from "./sdk-generation"; +import { registerDiagnostics } from "./diagnostics"; class CodeActionsProvider implements vscode.CodeActionProvider { constructor( @@ -108,13 +109,7 @@ class CodeActionsProvider implements vscode.CodeActionProvider { return; } - for (const connectorResult of enclosingService.resolvedConnectors) { - const connector = connectorResult.tryReadValue; - if (!connector) { - // Parsing error in the connector, skip - continue; - } - + for (const connector of enclosingService.resolvedConnectors) { results.push({ title: `Move to "${connector.value.connectorId}"`, kind: vscode.CodeActionKind.Refactor, @@ -140,6 +135,7 @@ export function registerFdc( emulatorController: EmulatorsController, telemetryLogger: TelemetryLogger, ): Disposable { + registerDiagnostics(context, dataConnectConfigs); const dataConnectToolkit = new DataConnectToolkit(broker); const codeActions = vscode.languages.registerCodeActionsProvider( @@ -211,6 +207,8 @@ export function registerFdc( ); }); + registerDataConnectConfigs(context, broker); + return Disposable.from( dataConnectToolkit, codeActions, @@ -224,8 +222,12 @@ export function registerFdc( selectedProjectStatus.show(); }), }, - registerDataConnectConfigs(broker), - registerExecution(context, broker, fdcService, telemetryLogger), + registerExecution( + context, + broker, + fdcService, + telemetryLogger, + ), registerExplorer(context, broker, fdcService), registerWebview({ name: "data-connect", context, broker }), registerAdHoc(fdcService, telemetryLogger), diff --git a/firebase-vscode/src/result.ts b/firebase-vscode/src/result.ts index ec2b74dfeec..4d36b6f0f24 100644 --- a/firebase-vscode/src/result.ts +++ b/firebase-vscode/src/result.ts @@ -3,83 +3,126 @@ * It has the added benefit of enabling the differentiation of "no value yet" * from "value is undefined". */ -export abstract class Result { +export abstract class Result { + private static wrapError( + error: unknown, + onError?: (error: unknown) => ErrorT, + ): ResultError { + if (onError) { + try { + return new ResultError(onError(error)); + } catch (error) { + return Result.wrapError(error, onError); + } + } + + return new ResultError(error as ErrorT); + } + /** Run a block of code and converts the result in a Result. * * Errors will be caught, logged and returned as an error. */ - static guard(cb: () => Promise): Promise>; - static guard(cb: () => T): Result; - static guard(cb: () => T | Promise): Result | Promise> { + static guard(cb: () => Promise): Promise>; + static guard( + cb: () => Promise, + onError?: (error: unknown) => ErrorT, + ): Result; + static guard( + cb: () => DataT, + onError?: (error: unknown) => ErrorT, + ): Result; + static guard( + cb: () => DataT | Promise, + onError?: (error: unknown) => ErrorT, + ): Result | Promise> { try { const value = cb(); if (value instanceof Promise) { return value - .then>((value) => new ResultValue(value)) - .catch((error) => new ResultError(error)); + .then>((value) => new ResultValue(value)) + .catch((err) => Result.wrapError(err, onError)); } return new ResultValue(value); - } catch (error: any) { - return new ResultError(error); + } catch (error: unknown) { + return Result.wrapError(error, onError); } } - get tryReadValue(): T | undefined { + get tryReadValue(): DataT | undefined { return this.switchCase( (value) => value, - () => undefined + () => undefined, ); } - get requireValue(): T { + get requireValue(): DataT { return this.switchCase( (value) => value, (error) => { - throw new Error("Result in error state", { - cause: error, - }); - } + throw error; + }, ); } switchCase( - value: (value: T) => NewT, - error: (error: unknown) => NewT + value: (value: DataT) => NewT, + error: (error: ErrorT) => NewT, ): NewT { const that: unknown = this; if (that instanceof ResultValue) { return value(that.value); } - return error((that as ResultError).error); + return error((that as ResultError).error); } - follow(cb: (prev: T) => Result): Result { + /** + * A `.then`-like method that guarantees to return a `Result` object. + * + * Any exception inside the callback will be caught and converted into an error + * result. + */ + follow( + cb: (prev: DataT) => Result, + onError?: (error: unknown) => ErrorT, + ): Result { return this.switchCase( (value) => cb(value), - (error) => new ResultError(error) + (error) => Result.wrapError(error, onError), ); } - followAsync( - cb: (prev: T) => Promise> - ): Promise> { - return this.switchCase>>( - (value) => cb(value), - async (error) => new ResultError(error) + /** + * A `.then`-like method that guarantees to return a `Result` object. + * It is the same as `follow`, but supports asynchronous callbacks. + */ + followAsync( + cb: (prev: DataT) => Promise>, + onError?: (error: unknown) => ErrorT, + ): Promise> { + return this.switchCase>>( + async (value) => { + try { + return await cb(value); + } catch (error) { + return Result.wrapError(error, onError); + } + }, + async (error) => Result.wrapError(error, onError), ); } } -export class ResultValue extends Result { - constructor(readonly value: T) { +export class ResultValue extends Result { + constructor(readonly value: DataT) { super(); } } -export class ResultError extends Result { - constructor(readonly error: unknown) { +export class ResultError extends Result { + constructor(readonly error: ErrorT) { super(); } } diff --git a/firebase-vscode/src/test/suite/src/core/config.test.ts b/firebase-vscode/src/test/suite/src/core/config.test.ts index 5cb4cd8d1fc..c371a649f24 100644 --- a/firebase-vscode/src/test/suite/src/core/config.test.ts +++ b/firebase-vscode/src/test/suite/src/core/config.test.ts @@ -13,12 +13,11 @@ import { registerConfig, } from "../../../../core/config"; import { - addDisposable, addTearDown, firebaseSuite, firebaseTest, } from "../../../utils/test_hooks"; -import { createFake, mock } from "../../../utils/mock"; +import { createFake, createFakeContext, mock } from "../../../utils/mock"; import { resetGlobals } from "../../../../utils/globals"; import { workspace } from "../../../../utils/test_hooks"; import { createFile, createTemporaryDirectory } from "../../../utils/fs"; @@ -182,61 +181,38 @@ firebaseSuite("_readFirebaseConfig", () => { }; const dir = createTemporaryDirectory(); - createFile(dir, "firebase.json", JSON.stringify(expectedConfig)); - - mock(workspace, { - workspaceFolders: [ - createFake({ - uri: vscode.Uri.file(dir), - }), - ], - }); + const path = createFile( + dir, + "firebase.json", + JSON.stringify(expectedConfig), + ); - const config = _readFirebaseConfig(); + const config = _readFirebaseConfig(vscode.Uri.parse(path)); assert.deepEqual(config?.requireValue!.data, expectedConfig); }); firebaseTest("returns undefined if firebase.json is not found", () => { const dir = createTemporaryDirectory(); - mock(workspace, { - workspaceFolders: [ - createFake({ - uri: vscode.Uri.file(dir), - }), - ], - }); - - const config = _readFirebaseConfig(); + const config = _readFirebaseConfig( + vscode.Uri.parse(`${dir}/firebase.json`), + ); assert.deepEqual(config, undefined); }); firebaseTest("throws if firebase.json is invalid", () => { const logs = spyLogs(); const dir = createTemporaryDirectory(); - createFile(dir, "firebase.json", "invalid json"); - - mock(workspace, { - workspaceFolders: [ - createFake({ - uri: vscode.Uri.file(dir), - }), - ], - }); + const path = createFile(dir, "firebase.json", "invalid json"); assert.equal(logs.error.length, 0); assert.throws( - () => _readFirebaseConfig(), + () => _readFirebaseConfig(vscode.Uri.parse(path)), (thrown) => thrown! .toString() - .startsWith( - `FirebaseError: There was an error loading ${path.join( - dir, - "firebase.json", - )}:`, - ), + .startsWith(`FirebaseError: There was an error loading ${path}:`), ); assert.equal(logs.error.length, 1); @@ -253,17 +229,9 @@ firebaseSuite("_readRC", () => { }; const dir = createTemporaryDirectory(); - createFile(dir, ".firebaserc", JSON.stringify(expectedConfig)); + const path = createFile(dir, ".firebaserc", JSON.stringify(expectedConfig)); - mock(workspace, { - workspaceFolders: [ - createFake({ - uri: vscode.Uri.file(dir), - }), - ], - }); - - const config = _readRC(); + const config = _readRC(vscode.Uri.parse(path)); assert.deepEqual( config?.requireValue!.data.projects, expectedConfig.projects, @@ -273,35 +241,19 @@ firebaseSuite("_readRC", () => { firebaseTest("returns undefined if .firebaserc is not found", () => { const dir = createTemporaryDirectory(); - mock(workspace, { - workspaceFolders: [ - createFake({ - uri: vscode.Uri.file(dir), - }), - ], - }); - - const config = _readRC(); + const config = _readRC(vscode.Uri.parse(`${dir}/.firebaserc`)); assert.deepEqual(config, undefined); }); firebaseTest("throws if .firebaserc is invalid", () => { const logs = spyLogs(); const dir = createTemporaryDirectory(); - createFile(dir, ".firebaserc", "invalid json"); - - mock(workspace, { - workspaceFolders: [ - createFake({ - uri: vscode.Uri.file(dir), - }), - ], - }); + const path = createFile(dir, ".firebaserc", "invalid json"); assert.equal(logs.error.length, 0); assert.throws( - () => _readRC(), + () => _readRC(vscode.Uri.parse(path)), (thrown) => thrown!.toString() === `SyntaxError: Unexpected token 'i', "invalid json" is not valid JSON`, @@ -333,11 +285,11 @@ firebaseSuite("_createWatcher", () => { mock(currentOptions, createFake({ cwd: dir })); - const watcher = _createWatcher("file")!; - addTearDown(() => watcher.dispose()); + const watcher = await _createWatcher("file")!; + addTearDown(() => watcher?.dispose()); const createdFile = new Promise((resolve) => { - watcher.onDidChange((e) => resolve(e)); + watcher?.onDidChange((e) => resolve(e)); }); fs.writeFileSync(file, "new content"); @@ -361,8 +313,8 @@ firebaseSuite("registerConfig", () => { firebaseConfig: expectedConfig, }); - const disposable = await registerConfig(broker); - addDisposable(disposable); + const context = createFakeContext(); + registerConfig(context, broker); // Initial register should not notify anything. assert.deepEqual(broker.sentLogs, []); @@ -389,8 +341,8 @@ firebaseSuite("registerConfig", () => { firebaseRc: initialRC, }); - const disposable = await registerConfig(broker); - addDisposable(disposable); + const context = createFakeContext(); + registerConfig(context, broker); assert.deepEqual(broker.sentLogs, []); @@ -428,8 +380,8 @@ firebaseSuite("registerConfig", () => { firebaseConfig: initialConfig, }); - const disposable = await registerConfig(broker); - addDisposable(disposable); + const context = createFakeContext(); + registerConfig(context, broker); assert.deepEqual(broker.sentLogs, []); @@ -437,7 +389,9 @@ firebaseSuite("registerConfig", () => { workspaces.byIndex(0)!.firebaseConfigPath, JSON.stringify(newConfig), ); - firebaseConfig.value = _readFirebaseConfig()!; + firebaseConfig.value = _readFirebaseConfig( + vscode.Uri.parse(workspaces.byIndex(0)!.firebaseConfigPath), + )!; assert.deepEqual(broker.sentLogs, [ { @@ -463,8 +417,8 @@ firebaseSuite("registerConfig", () => { const broker = createTestBroker(); mock(currentOptions, { ...currentOptions.value, cwd: undefined }); - const disposable = await registerConfig(broker); - addDisposable(disposable); + const context = createFakeContext(); + registerConfig(context, broker); // Should not throw. }); @@ -497,13 +451,13 @@ firebaseSuite("registerConfig", () => { }), ); - const disposable = await registerConfig(broker); - addDisposable(disposable); + const context = createFakeContext(); + registerConfig(context, broker); assert.equal(pendingWatchers.length, 3); assert.deepEqual(Object.keys(broker.onListeners), ["getInitialData"]); - disposable.dispose(); + context.subscriptions.forEach((sub) => sub.dispose()); assert.equal(pendingWatchers.length, 0); assert.deepEqual(Object.keys(broker.onListeners), []); @@ -560,8 +514,8 @@ firebaseSuite("registerConfig", () => { const broker = createTestBroker(); - const disposable = await registerConfig(broker); - addDisposable(disposable); + const context = createFakeContext(); + registerConfig(context, broker); const rcListeners = watcherListeners[".firebaserc"]!; const rcFile = path.join(dir, ".firebaserc"); @@ -629,8 +583,8 @@ firebaseSuite("registerConfig", () => { firebaseConfig: { emulators: { auth: { port: 9399 } } }, }); - const disposable = await registerConfig(broker); - addDisposable(disposable); + const context = createFakeContext(); + registerConfig(context, broker); broker.simulateOn("getInitialData"); diff --git a/firebase-vscode/src/test/suite/src/dataconnect/config.test.ts b/firebase-vscode/src/test/suite/src/dataconnect/config.test.ts index 588bc66fc30..60c0b49e792 100644 --- a/firebase-vscode/src/test/suite/src/dataconnect/config.test.ts +++ b/firebase-vscode/src/test/suite/src/dataconnect/config.test.ts @@ -1,13 +1,13 @@ import assert from "assert"; import { createTestBroker } from "../../../utils/broker"; -import { firebaseSuite, addDisposable } from "../../../utils/test_hooks"; +import { firebaseSuite } from "../../../utils/test_hooks"; import { setupMockTestWorkspaces } from "../../../utils/workspace"; import { dataConnectConfigs, registerDataConnectConfigs, } from "../../../../data-connect/config"; import { createTemporaryDirectory } from "../../../utils/fs"; -import { createFake, mock } from "../../../utils/mock"; +import { createFake, createFakeContext, mock } from "../../../utils/mock"; import { workspace } from "../../../../utils/test_hooks"; import * as vscode from "vscode"; import * as fs from "fs"; @@ -21,8 +21,8 @@ firebaseSuite("registerDataConnectConfigs", async () => { firebaseConfig: { emulators: { dataconnect: { port: 9399 } } }, }); - const disposable = registerDataConnectConfigs(broker); - addDisposable(disposable); + const context = createFakeContext(); + registerDataConnectConfigs(context, broker); broker.simulateOn("getInitialData"); @@ -94,9 +94,9 @@ firebaseSuite("registerDataConnectConfigs", async () => { }), ); + const context = createFakeContext(); const broker = createTestBroker(); - const disposable = await registerDataConnectConfigs(broker); - addDisposable(disposable); + registerDataConnectConfigs(context, broker); const dataConnectListeners = watcherListeners["**/{dataconnect,connector}.yaml"]!; diff --git a/firebase-vscode/src/test/utils/mock.ts b/firebase-vscode/src/test/utils/mock.ts index c64b6f8ead9..a71a3bdb5e3 100644 --- a/firebase-vscode/src/test/utils/mock.ts +++ b/firebase-vscode/src/test/utils/mock.ts @@ -1,5 +1,6 @@ import { Ref } from "../../utils/test_hooks"; import { addTearDown } from "./test_hooks"; +import * as vscode from "vscode"; /** A function that creates a new object which partially an interface. * @@ -35,3 +36,15 @@ export function mock(ref: Ref, value: Partial | undefined) { // Unsafe cast, but it's fine because we're only using this in tests. ref.value = fake as T; } + +export function createFakeContext(): vscode.ExtensionContext { + const context = createFake({ + subscriptions: [], + }); + + addTearDown(() => { + context.subscriptions.forEach((sub) => sub.dispose()); + }); + + return context; +} diff --git a/firebase-vscode/webviews/SidebarApp.tsx b/firebase-vscode/webviews/SidebarApp.tsx index b3cdc507dde..e057cead62b 100644 --- a/firebase-vscode/webviews/SidebarApp.tsx +++ b/firebase-vscode/webviews/SidebarApp.tsx @@ -1,10 +1,12 @@ import React, { useEffect } from "react"; import { Spacer } from "./components/ui/Spacer"; -import { broker, brokerSignal } from "./globals/html-broker"; +import { broker, brokerSignal, useBroker } from "./globals/html-broker"; import { AccountSection } from "./components/AccountSection"; import { ProjectSection } from "./components/ProjectSection"; import { VSCodeButton, + VSCodeDropdown, + VSCodeOption, VSCodeProgressRing, } from "@vscode/webview-ui-toolkit/react"; import { Body, Label } from "./components/ui/Text"; @@ -176,6 +178,35 @@ function Content() { ); } +function ConfigPicker() { + const configs = useBroker("notifyFirebaseConfigListChanged", { + initialRequest: "getInitialFirebaseConfigList", + }); + + if (!configs || configs.values.length < 2) { + // Only show the picker when there are multiple configs + return <>; + } + + return ( + <> + + + + + broker.send("selectFirebaseConfig", (e.target as any).value) + } + > + {configs.values.map((uri) => ( + {uri} + ))} + + + ); +} + export function SidebarApp() { const isInitialized = useComputed(() => { return !!configs.value?.firebaseJson?.value && hasFdcConfigs.value; @@ -200,6 +231,7 @@ export function SidebarApp() { isMonospace={env.value?.env.isMonospace ?? false} /> )} + {user.value &&