diff --git a/packages/apollo-language-server/src/config.ts b/packages/apollo-language-server/src/config.ts index 931b06bc3a..78ccd5d079 100644 --- a/packages/apollo-language-server/src/config.ts +++ b/packages/apollo-language-server/src/config.ts @@ -1,7 +1,7 @@ import * as cosmiconfig from "cosmiconfig"; import { LoaderEntry } from "cosmiconfig"; import TypeScriptLoader from "@endemolshinegroup/cosmiconfig-typescript-loader"; -import { resolve } from "path"; +import { resolve, dirname } from "path"; import { readFileSync, existsSync } from "fs"; import { merge } from "lodash/fp"; @@ -153,13 +153,14 @@ export type ConfigResult = { // take a config with multiple project types and return // an array of individual types export const projectsFromConfig = ( - config: ApolloConfigFormat + config: ApolloConfigFormat, + configURI?: URI ): Array => { const configs = []; const { client, service, ...rest } = config; // XXX use casting detection - if (client) configs.push(new ClientConfig(config)); - if (service) configs.push(new ServiceConfig(config)); + if (client) configs.push(new ClientConfig(config, configURI)); + if (service) configs.push(new ServiceConfig(config, configURI)); return configs; }; @@ -204,8 +205,14 @@ export class ApolloConfig { this.service = rawConfig.service; } + get configDirURI() { + return this.configURI && this.configURI.fsPath.includes(".js") + ? URI.parse(dirname(this.configURI.fsPath)) + : this.configURI; + } + get projects() { - return projectsFromConfig(this.rawConfig); + return projectsFromConfig(this.rawConfig, this.configURI); } set tag(tag: string) { diff --git a/packages/apollo-language-server/src/fileSet.ts b/packages/apollo-language-server/src/fileSet.ts index f94db921a2..b1ec50fd1f 100644 --- a/packages/apollo-language-server/src/fileSet.ts +++ b/packages/apollo-language-server/src/fileSet.ts @@ -2,32 +2,33 @@ import { relative } from "path"; import minimatch = require("minimatch"); import * as glob from "glob"; import { invariant } from "@apollographql/apollo-tools"; +import URI from "vscode-uri"; export class FileSet { - private rootPath: string; + private rootURI: URI; private includes: string[]; private excludes: string[]; constructor({ - rootPath, + rootURI, includes, excludes }: { - rootPath: string; + rootURI: URI; includes: string[]; excludes: string[]; }) { - invariant(rootPath, `Must provide "rootPath".`); + invariant(rootURI, `Must provide "rootURI".`); invariant(includes, `Must provide "includes".`); invariant(excludes, `Must provide "excludes".`); - this.rootPath = rootPath; + this.rootURI = rootURI; this.includes = includes; this.excludes = excludes; } includesFile(filePath: string): boolean { - filePath = relative(this.rootPath, filePath); + filePath = relative(this.rootURI.fsPath, filePath); return ( this.includes.some(include => minimatch(filePath, include)) && @@ -38,12 +39,12 @@ export class FileSet { allFiles(): string[] { return this.includes .flatMap(include => - glob.sync(include, { cwd: this.rootPath, absolute: true }) + glob.sync(include, { cwd: this.rootURI.fsPath, absolute: true }) ) .filter( filePath => !this.excludes.some(exclude => - minimatch(relative(this.rootPath, filePath), exclude) + minimatch(relative(this.rootURI.fsPath, filePath), exclude) ) ); } diff --git a/packages/apollo-language-server/src/languageProvider.ts b/packages/apollo-language-server/src/languageProvider.ts index 0deca345fa..dee82ea6ea 100644 --- a/packages/apollo-language-server/src/languageProvider.ts +++ b/packages/apollo-language-server/src/languageProvider.ts @@ -93,6 +93,15 @@ function symbolForFieldDefinition( export class GraphQLLanguageProvider { constructor(public workspace: GraphQLWorkspace) {} + async provideStats(uri?: DocumentUri) { + if (this.workspace.projects.length && uri) { + const project = this.workspace.projectForFile(uri); + return project ? project.getProjectStats() : { loaded: false }; + } + + return { loaded: false }; + } + async provideCompletionItems( uri: DocumentUri, position: Position, diff --git a/packages/apollo-language-server/src/project/base.ts b/packages/apollo-language-server/src/project/base.ts index 3aa752ec58..00ec76c0fd 100644 --- a/packages/apollo-language-server/src/project/base.ts +++ b/packages/apollo-language-server/src/project/base.ts @@ -1,6 +1,6 @@ import { extname } from "path"; import { readFileSync } from "fs"; -import Uri from "vscode-uri"; +import URI from "vscode-uri"; import { TypeSystemDefinitionNode, @@ -48,6 +48,22 @@ export interface GraphQLProjectConfig { fileSet: FileSet; loadingHandler: LoadingHandler; } + +export interface TypeStats { + service?: number; + client?: number; + total?: number; +} + +export interface ProjectStats { + type: string; + loaded: boolean; + serviceId?: string; + types?: TypeStats; + tag?: string; + lastFetch?: number; +} + export abstract class GraphQLProject implements GraphQLSchemaProvider { public schemaProvider: GraphQLSchemaProvider; protected _onDiagnostics?: NotificationHandler; @@ -65,6 +81,8 @@ export abstract class GraphQLProject implements GraphQLSchemaProvider { private fileSet: FileSet; protected loadingHandler: LoadingHandler; + protected lastLoadDate?: number; + constructor({ config, fileSet, @@ -106,6 +124,8 @@ export abstract class GraphQLProject implements GraphQLSchemaProvider { protected abstract initialize(): Promise[]; + abstract getProjectStats(): ProjectStats; + get isReady(): boolean { return this._isReady; } @@ -124,10 +144,12 @@ export abstract class GraphQLProject implements GraphQLSchemaProvider { } public resolveSchema(config: SchemaResolveConfig): Promise { + this.lastLoadDate = +new Date(); return this.schemaProvider.resolveSchema(config); } public onSchemaChange(handler: NotificationHandler) { + this.lastLoadDate = +new Date(); return this.schemaProvider.onSchemaChange(handler); } @@ -136,7 +158,7 @@ export abstract class GraphQLProject implements GraphQLSchemaProvider { } includesFile(uri: DocumentUri) { - return this.fileSet.includesFile(Uri.parse(uri).fsPath); + return this.fileSet.includesFile(URI.parse(uri).fsPath); } async scanAllIncludedFiles() { @@ -144,7 +166,7 @@ export abstract class GraphQLProject implements GraphQLSchemaProvider { `Loading queries for ${this.displayName}`, (async () => { for (const filePath of this.fileSet.allFiles()) { - const uri = Uri.file(filePath).toString(); + const uri = URI.file(filePath).toString(); // If we already have query documents for this file, that means it was either // opened or changed before we got a chance to read it. @@ -157,7 +179,7 @@ export abstract class GraphQLProject implements GraphQLSchemaProvider { } fileDidChange(uri: DocumentUri) { - const filePath = Uri.parse(uri).fsPath; + const filePath = URI.parse(uri).fsPath; const extension = extname(filePath); const languageId = fileAssociations[extension]; diff --git a/packages/apollo-language-server/src/project/client.ts b/packages/apollo-language-server/src/project/client.ts index 7790bfef37..793ae8cc7a 100644 --- a/packages/apollo-language-server/src/project/client.ts +++ b/packages/apollo-language-server/src/project/client.ts @@ -91,7 +91,9 @@ export class GraphQLClientProject extends GraphQLProject { clientIdentity }: GraphQLClientProjectConfig) { const fileSet = new FileSet({ - rootPath: rootURI.fsPath, + // the URI of the folder _containing_ the apollo.config.js is the true project's root. + // if a config doesn't have a uri associated, we can assume the `rootURI` is the project's root. + rootURI: config.configDirURI || rootURI, includes: config.client.includes, excludes: config.client.excludes }); @@ -111,6 +113,33 @@ export class GraphQLClientProject extends GraphQLProject { return [this.scanAllIncludedFiles(), this.loadServiceSchema()]; } + public getProjectStats() { + // use this to remove primitives and internal fields for stats + const filterTypes = (type: string) => + !/^__|Boolean|ID|Int|String|Float/.test(type); + + // filter out primitives and internal Types for type stats to match engine + const serviceTypes = this.serviceSchema + ? Object.keys(this.serviceSchema.getTypeMap()).filter(filterTypes).length + : 0; + const totalTypes = this.schema + ? Object.keys(this.schema.getTypeMap()).filter(filterTypes).length + : 0; + + return { + type: "client", + serviceId: this.serviceID, + types: { + service: serviceTypes, + client: totalTypes - serviceTypes, + total: totalTypes + }, + tag: this.config.tag, + loaded: this.serviceID ? true : false, + lastFetch: this.lastLoadDate + }; + } + onDecorations(handler: (any: any) => void) { this._onDecorations = handler; } @@ -213,6 +242,7 @@ export class GraphQLClientProject extends GraphQLProject { ] = await engineClient.loadSchemaTagsAndFieldStats(serviceID); this._onSchemaTags && this._onSchemaTags([serviceID, schemaTags]); this.fieldStats = fieldStats; + this.lastLoadDate = +new Date(); this.generateDecorations(); })() diff --git a/packages/apollo-language-server/src/project/service.ts b/packages/apollo-language-server/src/project/service.ts index 7e76b4cdca..5944b0dc84 100644 --- a/packages/apollo-language-server/src/project/service.ts +++ b/packages/apollo-language-server/src/project/service.ts @@ -25,7 +25,7 @@ export class GraphQLServiceProject extends GraphQLProject { loadingHandler }: GraphQLServiceProjectConfig) { const fileSet = new FileSet({ - rootPath: rootURI.fsPath, + rootURI: config.configDirURI || rootURI, includes: config.service.includes, excludes: config.service.excludes }); @@ -43,4 +43,8 @@ export class GraphQLServiceProject extends GraphQLProject { } validate() {} + + getProjectStats() { + return { loaded: true, type: "service" }; + } } diff --git a/packages/apollo-language-server/src/server.ts b/packages/apollo-language-server/src/server.ts index df7357e434..eb5bbecdda 100644 --- a/packages/apollo-language-server/src/server.ts +++ b/packages/apollo-language-server/src/server.ts @@ -212,5 +212,10 @@ connection.onNotification( (selection: QuickPickItem) => workspace.updateSchemaTag(selection) ); +connection.onNotification("apollographql/getStats", async ({ uri }) => { + const status = await languageProvider.provideStats(uri); + connection.sendNotification("apollographql/statsLoaded", status); +}); + // Listen on the connection connection.listen(); diff --git a/packages/apollo-language-server/src/workspace.ts b/packages/apollo-language-server/src/workspace.ts index 03902661d1..533a72fef5 100644 --- a/packages/apollo-language-server/src/workspace.ts +++ b/packages/apollo-language-server/src/workspace.ts @@ -3,7 +3,6 @@ import { NotificationHandler, PublishDiagnosticsParams } from "vscode-languageserver"; -import Uri from "vscode-uri"; import { QuickPickItem } from "vscode"; import { GraphQLProject, DocumentUri } from "./project/base"; @@ -54,13 +53,13 @@ export class GraphQLWorkspace { ? new GraphQLClientProject({ config, loadingHandler: this.LanguageServerLoadingHandler, - rootURI: URI.file(folder.uri), + rootURI: URI.parse(folder.uri), clientIdentity }) : new GraphQLServiceProject({ config: config as ServiceConfig, loadingHandler: this.LanguageServerLoadingHandler, - rootURI: URI.file(folder.uri), + rootURI: URI.parse(folder.uri), clientIdentity }); @@ -98,14 +97,14 @@ export class GraphQLWorkspace { */ const apolloConfigFiles: string[] = fg.sync("**/apollo.config.@(js|ts)", { - cwd: Uri.parse(folder.uri).fsPath, + cwd: URI.parse(folder.uri).fsPath, absolute: true, ignore: "**/node_modules/**" }); apolloConfigFiles.push( ...fg.sync("**/package.json", { - cwd: Uri.parse(folder.uri).fsPath, + cwd: URI.parse(folder.uri).fsPath, absolute: true, ignore: "**/node_modules/**" }) diff --git a/packages/apollo/src/commands/client/codegen.ts b/packages/apollo/src/commands/client/codegen.ts index 339a6e4764..5ebbb54919 100644 --- a/packages/apollo/src/commands/client/codegen.ts +++ b/packages/apollo/src/commands/client/codegen.ts @@ -4,13 +4,11 @@ import * as path from "path"; import { Kind, DocumentNode } from "graphql"; import * as tty from "tty"; import { Gaze } from "gaze"; - -import Uri from "vscode-uri"; +import URI from "vscode-uri"; import { TargetType, default as generate } from "../../generate"; import { ClientCommand } from "../../Command"; -import URI from "vscode-uri"; const waitForKey = async () => { console.log("Press any key to stop."); @@ -218,7 +216,7 @@ export default class Generate extends ClientCommand { const watcher = new Gaze(this.project.config.client.includes); watcher.on("all", (event, file) => { // console.log("\nChange detected, generating types..."); - this.project.fileDidChange(Uri.file(file).toString()); + this.project.fileDidChange(URI.file(file).toString()); }); if (tty.isatty((process.stdin as any).fd)) { await waitForKey(); diff --git a/packages/apollo/src/generate.ts b/packages/apollo/src/generate.ts index a3efdfa3e9..5e08e5b51f 100644 --- a/packages/apollo/src/generate.ts +++ b/packages/apollo/src/generate.ts @@ -1,7 +1,7 @@ import { fs } from "apollo-codegen-core/lib/localfs"; import * as path from "path"; import { GraphQLSchema, DocumentNode, print } from "graphql"; -import Uri from "vscode-uri"; +import URI from "vscode-uri"; import { compileToIR, @@ -42,7 +42,7 @@ export type GenerationOptions = CompilerOptions & }; function toPath(uri: string): string { - return Uri.parse(uri).path; + return URI.parse(uri).path; } export default function generate( diff --git a/packages/vscode-apollo/package.json b/packages/vscode-apollo/package.json index 2e1101fdb8..b6104b5d1a 100644 --- a/packages/vscode-apollo/package.json +++ b/packages/vscode-apollo/package.json @@ -113,6 +113,11 @@ "command": "apollographql/reloadService", "title": "Reload schema", "category": "Apollo" + }, + { + "command": "apollographql/showStatus", + "title": "Show Status", + "category": "Apollo" } ] } diff --git a/packages/vscode-apollo/src/extension.ts b/packages/vscode-apollo/src/extension.ts index a7123bc709..d6a3206513 100644 --- a/packages/vscode-apollo/src/extension.ts +++ b/packages/vscode-apollo/src/extension.ts @@ -17,8 +17,12 @@ import { ServerOptions, TransportKind } from "vscode-languageclient"; -import StatusBar from "./statusBar"; +import StatusBar from "./statusBar"; +import { + printStatsToClientOutputChannel, + printNoFileOpenMessage +} from "./utils"; const { version, referenceID } = require("../package.json"); export function activate(context: ExtensionContext) { @@ -42,9 +46,7 @@ export function activate(context: ExtensionContext) { run: { module: serverModule, transport: TransportKind.ipc, - options: { - env - } + options: { env } }, debug: { module: serverModule, @@ -86,6 +88,29 @@ export function activate(context: ExtensionContext) { context.subscriptions.push(client.start()); client.onReady().then(() => { + commands.registerCommand("apollographql/showStats", () => { + const fileUri = window.activeTextEditor + ? window.activeTextEditor.document.uri.fsPath + : null; + + // if no editor is open, but an output channel is, vscode returns something like + // output:extension-output-%234. If an editor IS open, this is something like file://Users/... + // This check is just for either a / or a \ anywhere in a fileUri + const fileOpen = fileUri && /[\/\\]/.test(fileUri); + + if (fileOpen) { + client.sendNotification("apollographql/getStats", { uri: fileUri }); + return; + } + printNoFileOpenMessage(client, version); + client.outputChannel.show(); + }); + + client.onNotification("apollographql/statsLoaded", params => { + printStatsToClientOutputChannel(client, params, version); + client.outputChannel.show(); + }); + commands.registerCommand("apollographql/reloadService", () => { // wipe out tags when reloading // XXX we should clean up this handling diff --git a/packages/vscode-apollo/src/statusBar.ts b/packages/vscode-apollo/src/statusBar.ts index e9d374384e..822164384b 100644 --- a/packages/vscode-apollo/src/statusBar.ts +++ b/packages/vscode-apollo/src/statusBar.ts @@ -10,7 +10,8 @@ export default class ApolloStatusBar { this.statusBarItem.text = ApolloStatusBar.loadingStateText; this.statusBarItem.show(); - // this.statusBarItem.command = "apollographql/showOutputChannel"; + this.statusBarItem.command = "apollographql/showStats"; + // context.subscriptions.push(this.statusBarItem); } public showLoadedState({ diff --git a/packages/vscode-apollo/src/utils.ts b/packages/vscode-apollo/src/utils.ts new file mode 100644 index 0000000000..62e099c6cf --- /dev/null +++ b/packages/vscode-apollo/src/utils.ts @@ -0,0 +1,82 @@ +import { LanguageClient } from "vscode-languageclient"; + +export const timeSince = (date: number) => { + const seconds = Math.floor((+new Date() - date) / 1000); + if (!seconds) return; + let interval = Math.floor(seconds / 86400); + if (interval >= 1) return `${interval}d`; + + interval = Math.floor(seconds / 3600); + if (interval >= 1) return `${interval}h`; + + interval = Math.floor(seconds / 60); + if (interval >= 1) return `${interval}m`; + + return `${Math.floor(seconds)}s`; +}; + +export const printNoFileOpenMessage = ( + client: LanguageClient, + extVersion: string +) => { + client.outputChannel.appendLine("------------------------------"); + client.outputChannel.appendLine(`🚀 Apollo GraphQL v${extVersion}`); + client.outputChannel.appendLine("------------------------------"); +}; + +export interface TypeStats { + service?: number; + client?: number; + total?: number; +} + +export interface ProjectStats { + type: string; + loaded: boolean; + serviceId?: string; + types?: TypeStats; + tag?: string; + lastFetch?: number; +} + +export const printStatsToClientOutputChannel = ( + client: LanguageClient, + stats: ProjectStats, + extVersion: string +) => { + client.outputChannel.appendLine("------------------------------"); + client.outputChannel.appendLine(`🚀 Apollo GraphQL v${extVersion}`); + client.outputChannel.appendLine("------------------------------"); + + if (!stats || !stats.loaded) { + client.outputChannel.appendLine( + "❌ Service stats could not be loaded. This may be because you're missing an apollo.config.js file " + + "or it is misconfigured. For more information about configuring Apollo projects, " + + "see the guide here (https://bit.ly/2ByILPj)." + ); + return; + } + + // we don't support logging of stats for service projects currently + if (stats.type === "service") { + return; + } else if (stats.type === "client") { + client.outputChannel.appendLine("✅ Service Loaded!"); + client.outputChannel.appendLine(`🆔 Service ID: ${stats.serviceId}`); + client.outputChannel.appendLine(`🏷 Schema Tag: ${stats.tag}`); + + if (stats.types) + client.outputChannel.appendLine( + `📈 Number of Types: ${stats.types.total} (${ + stats.types.client + } client ${stats.types.client === 1 ? "type" : "types"})` + ); + + if (stats.lastFetch && timeSince(stats.lastFetch)) { + client.outputChannel.appendLine( + `🗓 Last Fetched ${timeSince(stats.lastFetch)} Ago` + ); + } + client.outputChannel.appendLine("------------------------------"); + } +};