diff --git a/firebase-vscode/common/messaging/protocol.ts b/firebase-vscode/common/messaging/protocol.ts index bac572d116a..9b1a1897880 100644 --- a/firebase-vscode/common/messaging/protocol.ts +++ b/firebase-vscode/common/messaging/protocol.ts @@ -120,6 +120,9 @@ export interface WebviewToExtensionParamsMap { chooseQuickstartDir: {}; notifyAuthUserMockChange: UserMock; + + /** Opens the "connect to instance" picker */ + connectToInstance: void; } export interface FirematResults { diff --git a/firebase-vscode/package.json b/firebase-vscode/package.json index f6c3acfd647..7c639a16ba8 100644 --- a/firebase-vscode/package.json +++ b/firebase-vscode/package.json @@ -49,6 +49,11 @@ "type": "boolean", "default": true, "description": "Enable web frameworks" + }, + "firebase.firemat.alwaysAllowMutationsInProduction": { + "type": "boolean", + "default": false, + "description": "Always allow mutations in production. If false (default), trying to run a mutation in production will open a confirmation modal." } } }, @@ -92,9 +97,14 @@ "id": "sidebar", "name": "Firebase" }, + { + "type": "webview", + "id": "firemat", + "name": "Firebase Data Connect" + }, { "id": "firebase.firemat.explorerView", - "name": "Firemat Explorer" + "name": "FDC Explorer" } ], "firebase-firemat-execution-view": [ diff --git a/firebase-vscode/src/core/project.ts b/firebase-vscode/src/core/project.ts index 77292baa13f..8b576d2f05f 100644 --- a/firebase-vscode/src/core/project.ts +++ b/firebase-vscode/src/core/project.ts @@ -48,7 +48,7 @@ export function registerProject({ context: ExtensionContext; broker: ExtensionBrokerImpl; }): Disposable { - effect(async () => { + const effect1 = effect(async () => { const user = currentUser.value; if (user) { pluginLogger.info("(Core:Project) New user detected, fetching projects"); @@ -60,68 +60,88 @@ export function registerProject({ } }); - effect(() => { + const effect2 = effect(() => { broker.send("notifyProjectChanged", { projectId: currentProject.value?.projectId ?? "", }); }); // Update .firebaserc with defined project ID - effect(() => { + const effect3 = effect(() => { const projectId = currentProjectId.value; if (projectId) { updateFirebaseRCProject(context, "default", currentProjectId.value); } }); - broker.on("getInitialData", () => { + // Initialize currentProjectId to default project ID + const effect4 = effect(() => { + if (!currentProjectId.value) { + currentProjectId.value = firebaseRC.value?.projects.default; + } + }); + + const onGetInitialData = broker.on("getInitialData", () => { broker.send("notifyProjectChanged", { projectId: currentProject.value?.projectId ?? "", }); }); - broker.on("selectProject", async () => { - if (process.env.MONOSPACE_ENV) { - pluginLogger.debug( - "selectProject: found MONOSPACE_ENV, " + - "prompting user using external flow", - ); - /** - * Monospace case: use Monospace flow - */ - const monospaceExtension = - vscode.extensions.getExtension("google.monospace"); - process.env.MONOSPACE_DAEMON_PORT = - monospaceExtension.exports.getMonospaceDaemonPort(); - try { - const projectId = await selectProjectInMonospace({ - projectRoot: currentOptions.value.cwd, - project: undefined, - isVSCE: true, - }); - - if (projectId) { - currentProjectId.value = projectId; - } - } catch (e) { - pluginLogger.error(e); - } - } else if (isServiceAccount.value) { - return; - } else { - try { - currentProjectId.value = await promptUserForProject( - userScopedProjects.value, + const selectProjectCommand = vscode.commands.registerCommand( + "firebase.selectProject", + async () => { + if (process.env.MONOSPACE_ENV) { + pluginLogger.debug( + "selectProject: found MONOSPACE_ENV, " + + "prompting user using external flow", ); - } catch (e) { - vscode.window.showErrorMessage(e.message); + /** + * Monospace case: use Monospace flow + */ + const monospaceExtension = + vscode.extensions.getExtension("google.monospace"); + process.env.MONOSPACE_DAEMON_PORT = + monospaceExtension.exports.getMonospaceDaemonPort(); + try { + const projectId = await selectProjectInMonospace({ + projectRoot: currentOptions.value.cwd, + project: undefined, + isVSCE: true, + }); + + if (projectId) { + currentProjectId.value = projectId; + } + } catch (e) { + pluginLogger.error(e); + } + } else if (isServiceAccount.value) { + return; + } else { + try { + currentProjectId.value = await promptUserForProject( + userScopedProjects.value, + ); + } catch (e) { + vscode.window.showErrorMessage(e.message); + } } - } - }); + }, + ); + + const onSelectProject = broker.on("selectProject", () => + vscode.commands.executeCommand("firebase.selectProject"), + ); - return { - dispose() {}, - }; + return vscode.Disposable.from( + selectProjectCommand, + { dispose: onGetInitialData }, + { dispose: onSelectProject }, + { dispose: effect1 }, + { dispose: effect2 }, + { dispose: effect3 }, + { dispose: effect4 }, + ); } /** Get the user to select a project */ diff --git a/firebase-vscode/src/firemat/code-lens-provider.ts b/firebase-vscode/src/firemat/code-lens-provider.ts index 44d71157442..c8bdc6a40ca 100644 --- a/firebase-vscode/src/firemat/code-lens-provider.ts +++ b/firebase-vscode/src/firemat/code-lens-provider.ts @@ -4,9 +4,10 @@ import { OperationLocation } from "./types"; import { Disposable } from "vscode"; import { isFirematEmulatorRunning } from "../core/emulators"; -import { Signal, computed } from "@preact/signals-core"; +import { Signal } from "@preact/signals-core"; import { firematConfig } from "../core/config"; import path from "path"; +import { selectedInstance } from "./connect-instance"; abstract class ComputedCodeLensProvider implements vscode.CodeLensProvider { private readonly _onChangeCodeLensesEmitter = new vscode.EventEmitter(); @@ -86,12 +87,23 @@ export class OperationCodeLensProvider extends ComputedCodeLensProvider { position: position, }; const opKind = x.operation as string; // query or mutation - const schemaPath = configs.schema.main.source; - if (isPathInside(document.fileName, schemaPath)) { + const isInSchemaFolder = isPathInside( + document.fileName, + configs.schema.main.source, + ); + const connectorPaths = Object.keys(configs.operationSet).map( + (key) => configs.operationSet[key]!.source, + ); + const isInOperationFolder = connectorPaths.every( + (path) => !isPathInside(document.fileName, path), + ); + const instance = this.watch(selectedInstance); + + if (instance && (isInSchemaFolder || isInOperationFolder)) { codeLenses.push( new vscode.CodeLens(range, { - title: `$(play) Execute ${opKind}`, + title: `$(play) Run (${instance})`, command: "firebase.firemat.executeOperation", tooltip: "Execute the operation (⌘+enter or Ctrl+Enter)", arguments: [x, operationLocation], @@ -99,12 +111,7 @@ export class OperationCodeLensProvider extends ComputedCodeLensProvider { ); } - const connectorPaths = Object.keys(configs.operationSet).map( - (key) => configs.operationSet[key]!.source, - ); - if ( - connectorPaths.every((path) => !isPathInside(document.fileName, path)) - ) { + if (isInOperationFolder) { codeLenses.push( new vscode.CodeLens(range, { title: `$(plug) Move to connector`, diff --git a/firebase-vscode/src/firemat/connect-instance.ts b/firebase-vscode/src/firemat/connect-instance.ts new file mode 100644 index 00000000000..49bff11e728 --- /dev/null +++ b/firebase-vscode/src/firemat/connect-instance.ts @@ -0,0 +1,82 @@ +import * as vscode from "vscode"; +import { registerWebview } from "../webview"; +import { ExtensionBrokerImpl } from "../extension-broker"; +import { isFirematEmulatorRunning } from "../core/emulators"; +import { computed, effect, signal } from "@preact/signals-core"; + +export const selectedInstance = signal(undefined); + +export function registerFirebaseDataConnectView( + context: vscode.ExtensionContext, + broker: ExtensionBrokerImpl, +): vscode.Disposable { + const instanceOptions = computed(() => { + // Some fake options + const options = ["asia-east1", "europe-north1", "wonderland2"]; + + // We start with the emulator option + const emulator = "emulator"; + + // TODO refactor "start emulator" logic to enable the picker to start emulators + if (isFirematEmulatorRunning.value) { + options.splice(0, 0, emulator); + } + + return options; + }); + + const selectedInstanceStatus = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + ); + selectedInstanceStatus.tooltip = "Select a Firebase instance"; + selectedInstanceStatus.command = "firebase.firemat.connectToInstance"; + + function syncStatusBarWithSelectedInstance() { + return effect(() => { + selectedInstanceStatus.text = selectedInstance.value ?? ""; + selectedInstanceStatus.show(); + }); + } + + // Handle cases where the emulator is the currently selected instance, + // and the emulator is stopped. + // This also initializes the selectedInstance value to the first instance. + function initializeSelectedInstance() { + return effect(() => { + const isSelectedInstanceInOptions = instanceOptions.value?.includes( + selectedInstance.value, + ); + + if (!isSelectedInstanceInOptions) { + selectedInstance.value = instanceOptions.value?.[0]; + } + }); + } + + return vscode.Disposable.from( + vscode.commands.registerCommand( + "firebase.firemat.connectToInstance", + async () => { + const selected = await vscode.window.showQuickPick( + instanceOptions.value, + ); + if (!selected) { + return; + } + + selectedInstance.value = selected; + }, + ), + + selectedInstanceStatus, + { dispose: syncStatusBarWithSelectedInstance() }, + { dispose: initializeSelectedInstance() }, + { + dispose: broker.on("connectToInstance", async () => { + vscode.commands.executeCommand("firebase.firemat.connectToInstance"); + }), + }, + + registerWebview({ name: "firemat", context, broker }), + ); +} diff --git a/firebase-vscode/src/firemat/execution.ts b/firebase-vscode/src/firemat/execution.ts index a7f711134ce..48f6d256df8 100644 --- a/firebase-vscode/src/firemat/execution.ts +++ b/firebase-vscode/src/firemat/execution.ts @@ -1,4 +1,8 @@ -import vscode, { Disposable, ExtensionContext } from "vscode"; +import vscode, { + ConfigurationTarget, + Disposable, + ExtensionContext, +} from "vscode"; import { ExtensionBrokerImpl } from "../extension-broker"; import { registerWebview } from "../webview"; import { ExecutionHistoryTreeDataProvider } from "./execution-history-provider"; @@ -13,10 +17,11 @@ import { updateExecution, } from "./execution-store"; import { batch, effect } from "@preact/signals-core"; -import { OperationDefinitionNode, print } from "graphql"; +import { OperationDefinitionNode, OperationTypeNode, print } from "graphql"; import { FirematService } from "./service"; import { FirematError, toSerializedError } from "../../common/error"; import { OperationLocation } from "./types"; +import { selectedInstance } from "./connect-instance"; export function registerExecution( context: ExtensionContext, @@ -60,6 +65,34 @@ export function registerExecution( ast: OperationDefinitionNode, { document, documentPath, position }: OperationLocation, ) { + const configs = vscode.workspace.getConfiguration("firebase.firemat"); + const alwaysSettingsKey = "alwaysAllowMutationsInProduction"; + + // Warn against using mutations in production. + if ( + selectedInstance.value !== "emulator" && + !configs.get(alwaysSettingsKey) && + ast.operation === OperationTypeNode.MUTATION + ) { + const always = "Yes (always)"; + const yes = "Yes"; + const result = await vscode.window.showWarningMessage( + "You are about to perform a mutation in production environment. Are you sure?", + { modal: true }, + yes, + always, + ); + + if (result !== always && result !== yes) { + return; + } + + // If the user selects "always", we update User settings. + if (result === always) { + configs.update(alwaysSettingsKey, true, ConfigurationTarget.Global); + } + } + const item = createExecution({ label: ast.name?.value ?? "anonymous", timestamp: Date.now(), diff --git a/firebase-vscode/src/firemat/index.ts b/firebase-vscode/src/firemat/index.ts index ff13a98812a..0b7432c86a8 100644 --- a/firebase-vscode/src/firemat/index.ts +++ b/firebase-vscode/src/firemat/index.ts @@ -1,4 +1,5 @@ import vscode, { Disposable, ExtensionContext } from "vscode"; +import { effect } from "@preact/signals-core"; import { ExtensionBrokerImpl } from "../extension-broker"; import { registerExecution } from "./execution"; @@ -12,6 +13,8 @@ import { import { globalSignal } from "../utils/globals"; import { registerConnectors } from "./connectors"; import { AuthService } from "../auth/service"; +import { registerFirebaseDataConnectView } from "./connect-instance"; +import { currentProjectId } from "../core/project"; // import { setupLanguageClient } from "./language-client"; const firematEndpoint = globalSignal(undefined); @@ -38,9 +41,23 @@ export function registerFiremat( } }); + const selectedProjectStatus = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + ); + selectedProjectStatus.tooltip = "Select a Firebase project"; + selectedProjectStatus.command = "firebase.selectProject"; + return Disposable.from( + selectedProjectStatus, + { + dispose: effect(() => { + selectedProjectStatus.text = currentProjectId.value ?? ""; + selectedProjectStatus.show(); + }), + }, registerExecution(context, broker, firematService), registerExplorer(context, broker, firematService), + registerFirebaseDataConnectView(context, broker), registerAdHoc(context, broker), registerConnectors(context, broker, firematService), operationCodeLensProvider, diff --git a/firebase-vscode/webviews/firemat.entry.tsx b/firebase-vscode/webviews/firemat.entry.tsx new file mode 100644 index 00000000000..5790ee02d47 --- /dev/null +++ b/firebase-vscode/webviews/firemat.entry.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; +import { Spacer } from "./components/ui/Spacer"; +import styles from "./globals/index.scss"; +import { TEXT } from "./globals/ux-text"; +import { broker } from "./globals/html-broker"; + +// Prevent webpack from removing the `style` import above +styles; + +const root = createRoot(document.getElementById("root")!); +root.render(); + +function Firemat() { + return ( + <> + + {TEXT.DEPLOY_FIREMAT} + + broker.send("connectToInstance")}> + {TEXT.CONNECT_TO_INSTANCE} + + + ); +} diff --git a/firebase-vscode/webviews/globals/ux-text.ts b/firebase-vscode/webviews/globals/ux-text.ts index c1f7a67eccb..1068d28ba65 100644 --- a/firebase-vscode/webviews/globals/ux-text.ts +++ b/firebase-vscode/webviews/globals/ux-text.ts @@ -32,4 +32,8 @@ export const TEXT = { DEPLOYING_IN_PROGRESS: "Deploying...", DEPLOYING_PROGRESS_FRAMEWORK: "Deploying... this may take a few minutes.", + + DEPLOY_FIREMAT: "Deploy to production", + + CONNECT_TO_INSTANCE: "Connect to instance", };