diff --git a/packages/grammar-finder/README.md b/packages/grammar-finder/README.md new file mode 100644 index 0000000000..06ce98eb47 --- /dev/null +++ b/packages/grammar-finder/README.md @@ -0,0 +1,30 @@ +# Grammar-Finder + +Discover language grammars for unrecognized files. + +## AutoFind + +With 'AutoFind' enabled, when Pulsar fails to locate a grammar for the file you've just opened, defaulting to 'Plain Text', `grammar-finder` will automatically contact the Pulsar Package Registry in search of a community package that provides syntax highlighting for the file currently opened. + +If any packages are found you can easily view the whole list and install the one that looks best. + +When an 'AutoFind' notification appears you can quickly select: + * 'View Available Packages' to view the packages found. + * 'Disable Grammar-Finder for ' to add this extension to the `ignoreExtList`. + * 'Disable AutoFind' to disable 'AutoFind' completely. + +## Command Palette + +`grammar-finder` adds `grammar-finder:find-grammars-for-file` to the Command Palette, so that at any time you can check if any community packages provide syntax highlighting for the file you are currently working in. + +This makes it possible to find grammars for _recognized_ file types — or for unrecognized file types if you’ve disabled `autoFind`. + +## Configuration + +### `autoFind` + +When enabled, `autoFind` will show a notification inviting you to install a suitable grammar for an unrecognized file type. + +### `ignoreExtList` + +Any file extensions can be added to this list to disable all automatic checks for community packages for those file types. Choosing the “Disable `grammar-finder` for X” option on an `autoFind` notification will automatically add a given file extension to this list. This field should contain a comma-separated list of file extensions without any leading `.`s. diff --git a/packages/grammar-finder/lib/main.js b/packages/grammar-finder/lib/main.js new file mode 100644 index 0000000000..77d1912a3c --- /dev/null +++ b/packages/grammar-finder/lib/main.js @@ -0,0 +1,196 @@ +const { CompositeDisposable } = require("atom"); +const path = require("path"); +const PackageListView = require("./package-list-view.js"); + +class GrammarFinder { + activate() { + + // This local variable is intended to act as 'session' storage, or editing + // session storage. Where the next time the editor is opened it's info is gone + this.promptedForExt = []; + + atom.grammars.emitter.on("did-auto-assign-grammar", async (data) => { + if (!atom.config.get("grammar-finder.autoFind")) { + // autofind is turned off + return; + } + + let extOrFalse = this.inspectAssignment(data); + if (!extOrFalse) { + // We got false from inspectAssignment() we don't need to act + return; + } + + const ext = extOrFalse.replace(".", ""); + + const ignoreExtList = atom.config.get("grammar-finder.ignoreExtList"); + + if (ignoreExtList.includes(ext)) { + // we have been told to ignore this ext + return; + } + + if (this.promptedForExt.includes(ext)) { + // If we have already prompted for this extension in this editing session + return; + } + + const packages = await this.checkForGrammars(ext); + + if (packages.length === 0) { + // No packages were found that support this grammar + return; + } + + this.promptedForExt.push(ext); + + // Lets notify the user about the found packages + this.notify(packages, ext, "Pulsar couldn't identify an installed grammar for this file."); + }); + + this.disposables = new CompositeDisposable(); + + this.disposables.add( + atom.commands.add("atom-workspace", { + "grammar-finder:find-grammars-for-file": async () => { + // Here we can let users find a grammar for the current file, even if + // it's already correctly identified + const grammar = atom.workspace.getActiveTextEditor().getGrammar(); + const buffer = atom.workspace.getActiveTextEditor().buffer; + + let extOrFalse = this.inspectAssignment( + { + grammar: grammar, + buffer: buffer + }, + { + ignoreScope: true + } + ); + + if (!extOrFalse) { + // We didn't find any grammar, since this is manually invoked we may want to alert + atom.notifications.addInfo("Grammar-Finder was unable to identify the file.", { dismissable: true }); + return; + } + + let ext = extOrFalse.replace(".", ""); + + const ignoreExtList = atom.config.get("grammar-finder.ignoreExtList"); + + if (ignoreExtList.includes(ext)) { + // we have been told to ignore this ext, since manually invoked we may want to alert + atom.notifications.addInfo("This file is present on Grammar-Finder's ignore list.", { dismissable: true }); + return; + } + + const packages = await this.checkForGrammars(ext); + + if (packages.length === 0) { + // No packages were found that support this grammar + // since manuall invoked we may want to notify + atom.notifications.addInfo(`Unable to locate any Grammars for '${ext}'.`, { dismissable: true }); + return; + } + + // Lets notify the user about the found packages + this.notify(packages, ext, `'${packages.length}' Installable Grammars are available for '${ext}'.`); + } + }) + ); + } + + deactivate() { + this.superagent = null; + } + + inspectAssignment(data, opts = {}) { + console.log(`grammar-finder.inspectAssignment(${data.grammar.scopeName}, ${data.buffer.getPath()})`); + // data = { grammar, buffer } + // Lets first make sure that the grammar returned is one where no + // grammar could be found for the file. + + if (data.grammar.scopeName === "text.plain.null-grammar" || opts.ignoreScope) { + const filePath = data.buffer.getPath(); + + if (typeof filePath !== "string") { + return false; + } + + const parsed = path.parse(filePath); + // NodeJS thinks that if the `.` is the first character of a filename + // then it doesn't count as an extension. But according to our handling + // in Pulsar, the same isn't true. + let ext = false; + + if (typeof parsed.ext === "string" && parsed.ext.length > 0) { + ext = parsed.ext; + } else if (typeof parsed.name === "string" && parsed.name.length > 0) { + ext = parsed.name; + } + + console.log(`File: ${filePath} - Ext: ${ext}`); + + return ext; + } else { + return false; + } + } + + async checkForGrammars(ext) { + this.superagent ??= require("superagent"); + + const res = await fetch( + `https://api.pulsar-edit.dev/api/packages?fileExtension=${ext}`, + { + headers: { + "User-Agent": "Pulsar.Grammar-Finder" + } + } + ); + + if (res.status !== 200) { + // Return empty array + console.error(`Grammar-Finder received status '${res.status}' from the backend: ${res.body}`); + return []; + } + + return res.json(); + } + + notify(packages, ext, title) { + atom.notifications.addInfo( + title, + { + description: "Would you like to see installable packages that **may** support this file type?", + dismissable: true, + buttons: [ + { + text: "View Available Packages", + onDidClick: () => { + let packageListView = new PackageListView(packages); + packageListView.toggle(); + } + }, + { + text: `Don't suggest packages for '${ext}' files`, + onDidClick: () => { + let ignoreExtList = atom.config.get("grammar-finder.ignoreExtList"); + ignoreExtList.push(ext); + atom.config.set("grammar-finder.ignoreExtList", ignoreExtList); + } + }, + { + text: "Never suggest packages for unrecognized files", + onDidClick: () => { + atom.config.set("grammar-finder.autoFind", false); + } + } + ] + } + ); + + } +} + +module.exports = new GrammarFinder(); diff --git a/packages/grammar-finder/lib/package-list-view.js b/packages/grammar-finder/lib/package-list-view.js new file mode 100644 index 0000000000..d8ee005248 --- /dev/null +++ b/packages/grammar-finder/lib/package-list-view.js @@ -0,0 +1,234 @@ +const SelectListView = require("atom-select-list"); + +module.exports = +class PackageListView { + constructor(packageList) { + + this.packageList = packageList; + + this.packageListView = new SelectListView({ + itemsClassList: [ "mark-active" ], + items: [], + filterKeyForItem: (pack) => pack.name, + elementForItem: (pack) => { + const packageCard = document.createElement("div"); + packageCard.classList.add("package-card"); + + // === START OF STATS + const statContainer = document.createElement("div"); + statContainer.classList.add("stats", "pull-right"); + + const starSpan = document.createElement("span"); + starSpan.classList.add("stats-item"); + + const starIcon = document.createElement("span"); + starIcon.classList.add("icon", "icon-star"); + starSpan.appendChild(starIcon); + + const starCount = document.createElement("span"); + starCount.classList.add("value"); + starCount.textContent = pack.stargazers_count; + starSpan.appendChild(starCount); + + statContainer.appendChild(starSpan); + + const downSpan = document.createElement("span"); + downSpan.classList.add("stats-item"); + + const downIcon = document.createElement("span"); + downIcon.classList.add("icon", "icon-cloud-download"); + downSpan.appendChild(downIcon); + + const downCount = document.createElement("span"); + downCount.classList.add("value"); + downCount.textContent = pack.downloads; + downSpan.appendChild(downCount); + + statContainer.appendChild(downSpan); + + packageCard.appendChild(statContainer); + // === END OF STATS + + const body = document.createElement("div"); + body.classList.add("body"); + + const cardName = document.createElement("h4"); + cardName.classList.add("card-name"); + + const packageName = document.createElement("a"); + packageName.classList.add("package-name"); + packageName.textContent = pack.name; + packageName.href = `https://web.pulsar-edit.dev/packages/${pack.name}`; + cardName.appendChild(packageName); + + const packageVersion = document.createElement("span"); + packageVersion.classList.add("package-version"); + packageVersion.textContent = pack.metadata.version; + cardName.appendChild(packageVersion); + + const badges = document.createElement("span"); + if (Array.isArray(pack.badges)) { + for (const badge of pack.badges) { + let badgeHTML = this.generateBadge(badge); + badges.appendChild(badgeHTML); + } + } + cardName.appendChild(badges); + + const packageDescription = document.createElement("span"); + packageDescription.classList.add("package-description"); + packageDescription.textContent = pack.metadata.description; + + body.appendChild(cardName); + body.appendChild(packageDescription); + + packageCard.appendChild(body); + + return packageCard; + }, + didConfirmSelection: (pack) => { + this.cancel(); + // Then we defer to `settings-view` to install the package + atom.workspace.open(`atom://settings-view/show-package?package=${pack.name}`); + }, + didCancelSelection: () => { + this.cancel(); + } + }); + + this.packageListView.element.classList.add("grammar-finder"); + } + + destroy() { + this.cancel(); + this.packageList = null; + return this.packageListView.destroy(); + } + + cancel() { + if (this.panel != null) { + this.panel.destroy(); + } + this.panel = null; + + if (this.previouslyFocusedElement) { + this.previouslyFocusedElement.focus(); + this.previouslyFocusedElement = null; + } + } + + attach() { + this.previouslyFocusedElement = document.activeElement; + if (this.panel == null) { + this.panel = atom.workspace.addModalPanel({ item: this.packageListView }); + } + this.packageListView.focus(); + this.packageListView.reset(); + } + + async toggle() { + if (this.panel != null) { + this.cancel(); + return; + } + + await this.packageListView.update({ items: this.packageList }); + this.attach(); + } + + generateBadge(badge) { + const hasLink = (typeof badge.link === "string"); + const hasText = (typeof badge.text === "string"); + const classes = () => { + switch(badge.type) { + case "warn": + return "badge-error"; + case "success": + return "badge-success"; + case "info": + return "badge-info"; + default: + return "badge"; + } + }; + const icons = () => { + switch(badge.type) { + case "warn": + return "icon-alert"; + case "success": + return "icon-check"; + case "info": + return "icon-info"; + default: + return ""; + } + }; + + if (hasLink) { + if (hasText) { + // Link and Text + const link = document.createElement("a"); + link.href = badge.link; + + const spanContainer = document.createElement("span"); + spanContainer.classList.add("badge", classes()); + spanContainer.textContent = badge.title; + + const icon = document.createElement("i"); + icon.classList.add("icon", icons()); + + spanContainer.appendChild(icon); + link.appendChild(spanContainer); + + return link; + + } else { + // Link no text + const link = document.createElement("a"); + link.href = badge.link; + + const spanContainer = document.createElement("span"); + spanContainer.classList.add("badge", classes()); + spanContainer.textContent = badge.title; + + const icon = document.createElement("i"); + icon.classList.add("icon", icons()); + + spanContainer.appendChild(icon); + link.appendChild(spanContainer); + + return link; + + } + } else { + if (hasText) { + // No link, has text + const spanContainer = document.createElement("span"); + spanContainer.classList.add("badge", classes()); + spanContainer.textContent = badge.title; + + const icon = document.createElement("i"); + icon.classList.add("icon", icons()); + + spanContainer.appendChild(icon); + + return spanContainer; + + } else { + // no link and no text + const spanContainer = document.createElement("span"); + spanContainer.classList.add("badge", classes()); + spanContainer.textContent = badge.title; + + const icon = document.createElement("i"); + icon.classList.add("icon", icons()); + + spanContainer.appendChild(icon); + + return spanContainer; + + } + } + + } +} diff --git a/packages/grammar-finder/package-lock.json b/packages/grammar-finder/package-lock.json new file mode 100644 index 0000000000..f4a1b4645b --- /dev/null +++ b/packages/grammar-finder/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "grammar-finder", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "grammar-finder", + "license": "MIT", + "dependencies": { + "atom-select-list": "pulsar-edit/atom-select-list" + }, + "engines": { + "atom": ">=1.114" + } + }, + "node_modules/atom-select-list": { + "version": "0.8.1", + "resolved": "git+ssh://git@github.com/pulsar-edit/atom-select-list.git#a7861d70f8af532971dd3f0b87500e94b603200e", + "license": "MIT", + "dependencies": { + "etch": "^0.14.0", + "fuzzaldrin": "^2.1.0" + } + }, + "node_modules/atom-select-list/node_modules/etch": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/etch/-/etch-0.14.1.tgz", + "integrity": "sha512-+IwqSDBhaQFMUHJu4L/ir0dhDoW5IIihg4Z9lzsIxxne8V0PlSg0gnk2STaKWjGJQnDR4cxpA+a/dORX9kycTA==" + }, + "node_modules/fuzzaldrin": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fuzzaldrin/-/fuzzaldrin-2.1.0.tgz", + "integrity": "sha512-zgllBYwfHR5P3CncJiGbGVPpa3iFYW1yuPaIv8DiTVRrcg5/6uETNL5zvIoKflG1aifXVUZTlIroDehw4WygGA==" + } + }, + "dependencies": { + "atom-select-list": { + "version": "git+ssh://git@github.com/pulsar-edit/atom-select-list.git#a7861d70f8af532971dd3f0b87500e94b603200e", + "from": "atom-select-list@pulsar-edit/atom-select-list", + "requires": { + "etch": "^0.14.0", + "fuzzaldrin": "^2.1.0" + }, + "dependencies": { + "etch": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/etch/-/etch-0.14.1.tgz", + "integrity": "sha512-+IwqSDBhaQFMUHJu4L/ir0dhDoW5IIihg4Z9lzsIxxne8V0PlSg0gnk2STaKWjGJQnDR4cxpA+a/dORX9kycTA==" + } + } + }, + "fuzzaldrin": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fuzzaldrin/-/fuzzaldrin-2.1.0.tgz", + "integrity": "sha512-zgllBYwfHR5P3CncJiGbGVPpa3iFYW1yuPaIv8DiTVRrcg5/6uETNL5zvIoKflG1aifXVUZTlIroDehw4WygGA==" + } + } +} diff --git a/packages/grammar-finder/package.json b/packages/grammar-finder/package.json new file mode 100644 index 0000000000..c394cb29d5 --- /dev/null +++ b/packages/grammar-finder/package.json @@ -0,0 +1,28 @@ +{ + "name": "grammar-finder", + "verison": "1.0.0", + "main": "./lib/main.js", + "description": "Discover language packages for unrecognized files", + "license": "MIT", + "repository": "https://github.com/pulsar-edit/pulsar", + "engines": { + "atom": ">=1.114" + }, + "dependencies": { + "atom-select-list": "pulsar-edit/atom-select-list" + }, + "configSchema": { + "autoFind": { + "type": "boolean", + "default": true, + "description": "Look for available grammars when Pulsar is unable to find one locally." + }, + "ignoreExtList": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + } + } +} diff --git a/packages/grammar-finder/styles/grammar-finder.less b/packages/grammar-finder/styles/grammar-finder.less new file mode 100644 index 0000000000..b6c48faf22 --- /dev/null +++ b/packages/grammar-finder/styles/grammar-finder.less @@ -0,0 +1,90 @@ +@import "ui-variables"; + +@package-card-background-color: lighten(@tool-panel-background-color, 8%); + +.list-group .package-card.selected::before { + height: fit-content; +} + +.package-card.selected { + background-color: @background-color-selected; +} + +.package-card { + padding: (@component-padding * 1.5); + margin-bottom: @component-padding; + overflow: hidden; + font-size: 1.2em; + background-color: @package-card-background-color; + + .package-name { + margin-right: .5em; + font-weight: bolder; + color: @text-color-highlight; + } + + .package-version { + font-size: .8em; + margin-right: @component-padding; + } + + .description { + color: @text-color; + overflow: hidden; + min-height: 38px; + max-height: 38px; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + .card-name { + font-weight: 300; + margin: 0 0 .2em 0; + font-size: 1.2em; + line-height: 1.4; + } + + .package-description { + white-space: normal; + line-height: 1.4; + } + + .downloads { + color: @text-color-highlight; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + } + + .stats { + + .stats-item { + margin-left: (@component-padding*1.5); + height: 26px; + display: inline-block; + line-height: 24px; + vertical-align: top; + + .icon { + color: @text-color-subtle; + } + } + + .star-box { + display: inline-block; + vertical-align: top; + + .star-button { + padding: 0 6px; + + .octicon { + margin-right: 0px; + } + } + } + } +} diff --git a/src/grammar-registry.js b/src/grammar-registry.js index 0a233c7831..a0ce8997e3 100644 --- a/src/grammar-registry.js +++ b/src/grammar-registry.js @@ -193,6 +193,10 @@ module.exports = class GrammarRegistry { buffer.getPath(), getGrammarSelectionContent(buffer) ); + + // Emit an event whenever a grammar is auto-assigned + this.emitter.emit("did-auto-assign-grammar", { grammar: result.grammar, buffer: buffer }); + this.languageOverridesByBufferId.delete(buffer.id); this.grammarScoresByBuffer.set(buffer, result.score); if (result.grammar !== buffer.getLanguageMode().grammar) {