Skip to content

Commit 9132b4e

Browse files
authored
[VS Code] Add a code lens to refine / generate FDC operation (#9204)
1 parent 926f71b commit 9132b4e

File tree

11 files changed

+194
-52
lines changed

11 files changed

+194
-52
lines changed

firebase-vscode/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
## NEXT
22

3+
- [Added] Refine / Generate Operation Code Lens.
4+
35
## 1.8.0
46

57
- [Changed] Gemini Code Assist is now optionally installed when using the "Build with AI" feature

firebase-vscode/src/analytics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export enum DATA_CONNECT_EVENT_NAME {
2121
PROJECT_SELECTED = "project_selected",
2222
RUN_LOCAL = "run_local",
2323
RUN_PROD = "run_prod",
24+
GENERATE_OPERATION = "generate_operation",
2425
ADD_DATA = "add_data",
2526
READ_DATA = "read_data",
2627
MOVE_TO_CONNECTOR = "move_to_connector",

firebase-vscode/src/data-connect/code-lens-provider.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { Disposable } from "vscode";
66
import { Signal } from "@preact/signals-core";
77
import { dataConnectConfigs, firebaseRC } from "./config";
88
import { EmulatorsController } from "../core/emulators";
9+
import { GenerateOperationInput } from "./execution/execution";
10+
import { findCommentsBlocks } from "../utils/find_comments";
911

1012
export enum InstanceType {
1113
LOCAL = "local",
@@ -85,6 +87,7 @@ export class OperationCodeLensProvider extends ComputedCodeLensProvider {
8587
for (let i = 0; i < documentNode.definitions.length; i++) {
8688
const x = documentNode.definitions[i];
8789
if (x.kind === Kind.OPERATION_DEFINITION && x.loc) {
90+
// startToken.line is 1-indexed, range is 0-indexed
8891
const line = x.loc.startToken.line - 1;
8992
const range = new vscode.Range(line, 0, line, 0);
9093
const position = new vscode.Position(line, 0);
@@ -97,17 +100,6 @@ export class OperationCodeLensProvider extends ComputedCodeLensProvider {
97100
document.fileName,
98101
);
99102
if (service) {
100-
// For demo purposes only
101-
// codeLenses.push(
102-
// new vscode.CodeLens(range, {
103-
// title: `$(play) Refine Operation`,
104-
// command: "firebase.dataConnect.refineOperation",
105-
// tooltip:
106-
// "Execute the operation (⌘+enter or Ctrl+Enter)",
107-
// arguments: [x, operationLocation, InstanceType.LOCAL],
108-
// }),
109-
// );
110-
111103
codeLenses.push(
112104
new vscode.CodeLens(range, {
113105
title: `$(play) Run (local)`,
@@ -131,6 +123,33 @@ export class OperationCodeLensProvider extends ComputedCodeLensProvider {
131123
}
132124
}
133125

126+
if (projectId) {
127+
const comments = findCommentsBlocks(documentText);
128+
for (let i = 0; i < comments.length; i++) {
129+
const c = comments[i];
130+
const range = new vscode.Range(c.startLine, 0, c.startLine, 0);
131+
const queryDoc = documentNode.definitions.find((d) =>
132+
d.kind === Kind.OPERATION_DEFINITION &&
133+
// startToken.line is 1-indexed, endLine is 0-indexed
134+
d.loc?.startToken.line === c.endLine + 2
135+
);
136+
const arg: GenerateOperationInput = {
137+
projectId,
138+
document: document,
139+
description: c.text,
140+
insertPosition: c.endIndex + 1,
141+
existingQuery: queryDoc?.loc ? documentText.substring(c.endIndex + 1, queryDoc.loc.endToken.end) : '',
142+
};
143+
codeLenses.push(
144+
new vscode.CodeLens(range, {
145+
title: queryDoc ? `$(sparkle) Refine Operation` : `$(sparkle) Generate Operation`,
146+
command: "firebase.dataConnect.generateOperation",
147+
tooltip: "Generate the operation (⌘+enter or Ctrl+Enter)",
148+
arguments: [arg],
149+
}),
150+
);
151+
}
152+
}
134153
return codeLenses;
135154
}
136155
}

firebase-vscode/src/data-connect/execution/execution.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,11 @@ import { InstanceType } from "../code-lens-provider";
3535
import { DATA_CONNECT_EVENT_NAME, AnalyticsLogger } from "../../analytics";
3636
import { getDefaultScalarValue } from "../ad-hoc-mutations";
3737
import { EmulatorsController } from "../../core/emulators";
38-
import { getConnectorGQLText } from "../file-utils";
38+
import { getConnectorGQLText, insertQueryAt } from "../file-utils";
3939
import { pluginLogger } from "../../logger-wrapper";
40+
import * as gif from "../../../../src/gemini/fdcExperience";
41+
import { ensureGIFApiTos } from "../../../../src/dataconnect/ensureApis";
42+
import { configstore } from "../../../../src/configstore";
4043

4144
interface TypedInput {
4245
varName: string;
@@ -49,6 +52,14 @@ interface ExecutionInput {
4952
instance: InstanceType;
5053
}
5154

55+
export interface GenerateOperationInput {
56+
projectId: string;
57+
document: vscode.TextDocument;
58+
description: string;
59+
insertPosition: number;
60+
existingQuery: string;
61+
}
62+
5263
export const lastExecutionInputSignal = new Signal<ExecutionInput | null>(null);
5364

5465
export function registerExecution(
@@ -302,6 +313,54 @@ export function registerExecution(
302313
}
303314
}
304315

316+
async function generateOperation(arg: GenerateOperationInput) {
317+
try {
318+
const schema = await dataConnectService.schema();
319+
const prompt = `Generate a Data Connect operation to match this description: ${arg.description}
320+
${arg.existingQuery ? `\n\nRefine this existing operation:\n${arg.existingQuery}` : ''}
321+
${schema ? `\n\nUse the Data Connect Schema:\n\`\`\`graphql
322+
${schema}
323+
\`\`\`` : ""}`;
324+
const serviceName = await dataConnectService.servicePath(arg.document.fileName);
325+
if (!(await ensureGIFApiTos(arg.projectId))) {
326+
if (!(await showGiFToSModal(arg.projectId))) {
327+
return; // ToS isn't accepted.
328+
}
329+
}
330+
const res = await gif.generateOperation(prompt, serviceName, arg.projectId);
331+
await insertQueryAt(arg.document.uri, arg.insertPosition, arg.existingQuery, res);
332+
} catch (e: any) {
333+
vscode.window.showErrorMessage(`Failed to generate query: ${e.message}`);
334+
}
335+
}
336+
337+
async function showGiFToSModal(projectId: string): Promise<boolean> {
338+
const tos = "Terms of Service";
339+
const enable = "Enable";
340+
const result = await vscode.window.showWarningMessage(
341+
"Gemini in Firebase",
342+
{
343+
modal: !process.env.VSCODE_TEST_MODE,
344+
detail: "Gemini in Firebase helps you write Data Connect queries.",
345+
},
346+
enable,
347+
tos,
348+
);
349+
switch (result) {
350+
case enable:
351+
configstore.set("gemini", true);
352+
await ensureGIFApiTos(projectId);
353+
return true;
354+
case tos:
355+
vscode.env.openExternal(
356+
vscode.Uri.parse(
357+
"https://firebase.google.com/docs/gemini-in-firebase#how-gemini-in-firebase-uses-your-data",
358+
),
359+
);
360+
}
361+
return false;
362+
}
363+
305364
const sub4 = broker.on(
306365
"definedDataConnectArgs",
307366
(value) => (executionArgsJSON.value = value),
@@ -333,7 +392,16 @@ export function registerExecution(
333392
: DATA_CONNECT_EVENT_NAME.RUN_PROD,
334393
);
335394
await vscode.window.activeTextEditor?.document.save();
336-
executeOperation(ast, location, instanceType);
395+
await executeOperation(ast, location, instanceType);
396+
},
397+
),
398+
vscode.commands.registerCommand(
399+
"firebase.dataConnect.generateOperation",
400+
async (arg: GenerateOperationInput) => {
401+
analyticsLogger.logger.logUsage(
402+
DATA_CONNECT_EVENT_NAME.GENERATE_OPERATION,
403+
);
404+
await generateOperation(arg);
337405
},
338406
),
339407
vscode.commands.registerCommand(

firebase-vscode/src/data-connect/file-utils.ts

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import * as fs from "fs";
44

55
import { dataConnectConfigs } from "./config";
66
import { pluginLogger } from "../logger-wrapper";
7-
import { parse } from "graphql";
87

98
export async function checkIfFileExists(file: Uri) {
109
try {
@@ -56,25 +55,23 @@ export function getHighlightedText(): string {
5655
return editor.document.getText(selectionRange);
5756
}
5857

59-
export function parseGraphql(content: string) {
60-
content = content.replaceAll("```", "");
61-
content = content.replaceAll("graphql", "");
62-
const documentNode = parse(content);
63-
return documentNode.definitions[0];
64-
}
65-
66-
export function insertToBottomOfActiveFile(text: string) {
67-
const editor = vscode.window.activeTextEditor;
68-
if (!editor) {
58+
export async function insertQueryAt(uri: vscode.Uri, at: number, existing: string, replace: string): Promise<void> {
59+
const doc = await vscode.workspace.openTextDocument(uri);
60+
const text = doc.getText();
61+
if (!existing) {
62+
const newText = text.slice(0, at) + replace + text.slice(at);
63+
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(newText));
6964
return;
7065
}
71-
const lastLine = editor.document.lineAt(editor.document.lineCount - 1);
72-
const escapedText = text.replace(/\$/g, "\\\$"); // escape $ symbols
73-
74-
editor.insertSnippet(
75-
new vscode.SnippetString(`\n\n${escapedText}`),
76-
lastLine.range.end,
77-
);
66+
if (text.slice(at, at + existing.length) !== existing) {
67+
throw new Error("The existing query was updated.");
68+
}
69+
// Adds a new line before if inserting at the end of the file
70+
if (at > text.length) {
71+
replace = "\n" + replace;
72+
}
73+
const newText = text.slice(0, at) + replace + text.slice(at + existing.length);
74+
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(newText));
7875
}
7976

8077
// given a file path, compile all gql files for the associated connector

firebase-vscode/src/data-connect/service.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export class DataConnectService {
120120
operationName: "IntrospectionQuery",
121121
variables: "{}",
122122
});
123-
console.log("introspection: ", introspectionResults);
123+
console.log("introspection result: ", introspectionResults);
124124
// TODO: handle errors
125125
if ((introspectionResults as any).errors.length > 0) {
126126
return { data: undefined };
@@ -138,6 +138,23 @@ export class DataConnectService {
138138
}
139139
}
140140

141+
// Fetch the local Data Connect Schema sources via the toolkit introspection service.
142+
async schema(): Promise<string> {
143+
try {
144+
const res = await this.executeGraphQLRead({
145+
query: `query { _service { schema } }`,
146+
operationName: "",
147+
variables: "{}",
148+
});
149+
console.log("introspection schema result: ", res);
150+
return (res as any)?.data?._service?.schema || "";
151+
} catch (e) {
152+
// TODO: surface error that emulator is not connected
153+
pluginLogger.error("error: ", e);
154+
return "";
155+
}
156+
}
157+
141158
async executeGraphQLRead(params: {
142159
query: string;
143160
operationName: string;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
interface Comment {
2+
text: string;
3+
startLine: number;
4+
endLine: number;
5+
endIndex: number;
6+
}
7+
8+
export function findCommentsBlocks(text: string): Comment[] {
9+
const lineEnds: number[] = [];
10+
let searchIndex: number = -1;
11+
while ((searchIndex = text.indexOf('\n', searchIndex + 1)) !== -1) {
12+
lineEnds.push(searchIndex);
13+
}
14+
lineEnds.push(text.length);
15+
const comments: Comment[] = [];
16+
for (let i = 0; i < lineEnds.length; i++) {
17+
const lineStart = i === 0 ? 0 : lineEnds[i - 1] + 1;
18+
const lineText = text.substring(lineStart, lineEnds[i]).trim();
19+
if (lineText.startsWith('#')) {
20+
comments.push({ startLine: i, endLine: i, text: lineText.substring(1).trim(), endIndex: lineEnds[i] });
21+
}
22+
}
23+
const commentBlocks: Comment[] = [];
24+
for (let i = 0; i < comments.length; i++) {
25+
const current = comments[i];
26+
if (i === 0 || current.startLine > comments[i - 1].endLine + 1) {
27+
commentBlocks.push({ ...current });
28+
} else {
29+
// Continuation of the previous block
30+
const lastBlock = commentBlocks[commentBlocks.length - 1];
31+
lastBlock.endLine = current.endLine;
32+
lastBlock.endIndex = current.endIndex;
33+
lastBlock.text += '\n' + current.text;
34+
}
35+
}
36+
return commentBlocks;
37+
}

src/dataconnect/ensureApis.spec.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,4 @@ describe("ensureApis", () => {
2121
expect(ensureStub).to.be.calledWith("my-project", api.dataconnectOrigin(), "dataconnect");
2222
expect(ensureStub).to.be.calledWith("my-project", api.cloudSQLAdminOrigin(), "dataconnect");
2323
});
24-
25-
it("should ensure Cloud AI Companion API is enabled", async () => {
26-
ensureStub.resolves();
27-
await apis.ensureGIFApis("my-project");
28-
expect(ensureStub).to.be.calledWith("my-project", api.cloudAiCompanionOrigin(), "dataconnect");
29-
});
3024
});

src/dataconnect/ensureApis.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as api from "../api";
2-
import { ensure } from "../ensureApiEnabled";
2+
import { configstore } from "../configstore";
3+
import { check, ensure } from "../ensureApiEnabled";
34

45
const prefix = "dataconnect";
56

@@ -10,6 +11,18 @@ export async function ensureApis(projectId: string, silent: boolean = false): Pr
1011
]);
1112
}
1213

13-
export async function ensureGIFApis(projectId: string): Promise<void> {
14-
await ensure(projectId, api.cloudAiCompanionOrigin(), prefix);
14+
/**
15+
* Check if GIF APIs are enabled.
16+
* If the Gemini in Firebase ToS is accepted, ensure the API is enabled.
17+
* Otherwise, return false. The caller should prompt the user to accept the ToS.
18+
*/
19+
export async function ensureGIFApiTos(projectId: string): Promise<boolean> {
20+
if (configstore.get("gemini")) {
21+
await ensure(projectId, api.cloudAiCompanionOrigin(), "");
22+
} else {
23+
if (!(await check(projectId, api.cloudAiCompanionOrigin(), ""))) {
24+
return false;
25+
}
26+
}
27+
return true;
1528
}

src/init/features/dataconnect/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Setup } from "../..";
88
import { setupCloudSql } from "../../../dataconnect/provisionCloudSql";
99
import { checkFreeTrialInstanceUsed, upgradeInstructions } from "../../../dataconnect/freeTrial";
1010
import * as cloudsql from "../../../gcp/cloudsql/cloudsqladmin";
11-
import { ensureApis, ensureGIFApis } from "../../../dataconnect/ensureApis";
11+
import { ensureApis, ensureGIFApiTos } from "../../../dataconnect/ensureApis";
1212
import {
1313
listLocations,
1414
listAllServices,
@@ -118,7 +118,7 @@ export async function askQuestions(setup: Setup): Promise<void> {
118118
});
119119
if (info.appDescription) {
120120
configstore.set("gemini", true);
121-
await ensureGIFApis(setup.projectId);
121+
await ensureGIFApiTos(setup.projectId);
122122
}
123123
}
124124
if (hasBilling) {

0 commit comments

Comments
 (0)