From 541f3fd41d9fd6fd3622d6f88ac29ec5cbfe9ce5 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 12 Jun 2022 20:26:19 -0500 Subject: [PATCH 01/13] Changes from other branches Reload symbols only after their order or names are changed Keep the header message accurate Supposedly improve type checking Keep a record that enables access to a symbol's updated location using its ID Remove unneeded properties Do not ignore all errors Make `watchConfigFile` function less exceptional It now takes an array of filenames and returns an array of disposables. It was also placed closer to `restartServerOnConfigChanges` and `restartServerOnWorkspaceConfigChanges`. Use the new `configFilenames` constant More concisely remove item from array --- src/commands/sidebar_find_symbol.ts | 169 +++++++++++++++++----------- 1 file changed, 104 insertions(+), 65 deletions(-) diff --git a/src/commands/sidebar_find_symbol.ts b/src/commands/sidebar_find_symbol.ts index ba39d32..8acd3fe 100644 --- a/src/commands/sidebar_find_symbol.ts +++ b/src/commands/sidebar_find_symbol.ts @@ -14,31 +14,36 @@ import { import { lsp } from "../../deps.ts"; let symbolDataProvider: SymbolDataProvider | null = null; -interface ElementConversionOptions { - shouldDisambiguate?: boolean; +function getFilename(uri: string) { + return decodeURIComponent(uri).split("/").pop()!; } -type Element = File | Header | Symbol; -class File { + +interface Element { + toTreeItem: () => TreeItem; + children: Element[]; +} +class File implements Element { uri: string; extension: string; children: Symbol[]; - constructor(uri: string, children: Symbol[]) { + shouldDisambiguate: boolean; + + constructor(uri: string, children: Symbol[], shouldDisambiguate: boolean) { this.uri = uri; this.extension = uri.split(".").pop()!; this.children = children; + this.shouldDisambiguate = shouldDisambiguate; } get filename(): string { - return decodeURIComponent(this.uri).split("/").pop()!; + return getFilename(this.uri); } - toTreeItem( - { shouldDisambiguate }: ElementConversionOptions = {}, - ) { + toTreeItem() { const path = decodeURIComponent(this.uri.slice(this.uri.indexOf(":") + 1)); const relativePath: string = nova.workspace.relativizePath(path); const item = new TreeItem( - shouldDisambiguate ? relativePath : this.filename, + this.shouldDisambiguate ? relativePath : this.filename, ); item.image = "__filetype." + this.extension; @@ -46,7 +51,7 @@ class File { return item; } } -class Header { +class Header implements Element { content: string; children: []; constructor(content: string) { @@ -59,15 +64,18 @@ class Header { return item; } } -class Symbol { +class Symbol implements Element { name: string; type: string; - location: lsp.Location; children: []; - constructor(lspSymbol: lsp.SymbolInformation) { + private getLocation: () => lsp.Location; + constructor( + lspSymbol: lsp.SymbolInformation, + getLocation: () => lsp.Location, + ) { this.name = lspSymbol.name; this.type = this.getType(lspSymbol); - this.location = lspSymbol.location; + this.getLocation = getLocation; this.children = []; } @@ -183,8 +191,8 @@ class Symbol { } async show() { - const uri = this.location.uri; - const lspRange = this.location.range; + const uri = this.getLocation().uri; + const lspRange = this.getLocation().range; const editor = await nova.workspace.openFile(uri); if (!editor) { @@ -198,11 +206,15 @@ class Symbol { } class SymbolDataProvider implements TreeDataProvider { private treeView: TreeView; - private symbols: Map; + + // The locations need to be stored separately to enable the sidebar to reload infrequently. + private locations: Map; private files: File[]; + + private previousNames: string[] | undefined; + private currentQuery: string | null; private headerMessage: string | null; - private ambiguousFilenames: string[]; constructor() { this.treeView = new TreeView("co.gwil.deno.sidebars.symbols.sections.1", { @@ -210,9 +222,8 @@ class SymbolDataProvider implements TreeDataProvider { }); this.treeView.onDidChangeSelection(this.onDidChangeSelection); - this.symbols = new Map(); + this.locations = new Map(); this.files = []; - this.ambiguousFilenames = []; this.headerMessage = null; this.currentQuery = null; @@ -235,52 +246,90 @@ class SymbolDataProvider implements TreeDataProvider { return this.treeView.reload(); } - private setSymbols(lspSymbols: lsp.SymbolInformation[]) { - const symbols = lspSymbols.filter((lspSymbol) => - // keep only symbols from real files - lspSymbol.location.uri.startsWith("file://") - ).map( - // turn into `Symbol`s - (lspSymbol) => new Symbol(lspSymbol), - ); + private setSymbols( + lspSymbols: lsp.SymbolInformation[], + didQueryChange: boolean, + ) { + this.locations.clear(); + + const symbolMap = new Map(); + const names: string[] = []; + const oldNames = this.previousNames ?? []; + + let index = 0; + for (const lspSymbol of lspSymbols) { + const { uri } = lspSymbol.location; + if (uri.startsWith("file://")) { + this.locations.set(index, lspSymbol.location); + + // We need to make a copy because `index` otherwise evaluates (usually, if not always) to the value to which it is bound at the end of the loop, rather than to the value to which it is bound at the time at which this code runs. I was very amazed by this behavior. Let me know if you, the reader, expected it. + const index1 = index; + const symbol = new Symbol(lspSymbol, () => this.locations.get(index1)!); + symbolMap.set(uri, [ + ...(symbolMap.get(uri) ?? []), + symbol, + ]); + names.push(symbol.name); + + index++; + } + } + + const seenFilenames: string[] = []; + const ambiguousFilenames: string[] = []; + for (const [uri] of symbolMap) { + const filename = getFilename(uri); + if (seenFilenames.includes(filename)) { + ambiguousFilenames.push(filename); + } + seenFilenames.push(filename); + } - this.symbols.clear(); - for (const symbol of symbols) { - const { uri } = symbol.location; - this.symbols.set( - uri, - [...(this.symbols.get(uri) ?? []), symbol], + const files: File[] = []; + for (const [uri, symbols] of symbolMap) { + const filename = getFilename(uri); + files.push( + new File(uri, symbols, ambiguousFilenames.includes(filename)), ); } - this.files = Array.from(this.symbols.keys()).map((uri) => - new File(uri, this.symbols.get(uri)!) - ); + this.files = files; + if (this.files.length) { + this.headerMessage = `Results for '${this.currentQuery}':`; + } else { + this.headerMessage = `No results found for '${this.currentQuery}'.`; + } - function findDuplicates(array: Type[]): Type[] { - const seenItems: Type[] = []; - const duplicates: Type[] = []; + function zip(...arrays: (Type[])[]): (Type[])[] { + const lengths = arrays.map((array) => array.length); + const largestArray = arrays[lengths.indexOf(Math.max(...lengths))]; + return largestArray.map((_item, index) => + arrays.map((item) => item[index]) + ); + } - for (const item of array) { - if (seenItems.includes(item)) { - duplicates.push(item); - } - seenItems.push(item); + // We need to reload if the query changes in order to keep the `headerMessage` accurate. + let shouldReload = didQueryChange; + for (const [newName, oldName] of zip(names, oldNames)) { + if (shouldReload) { + break; + } + if (newName != oldName) { + shouldReload = true; } - - return duplicates; } - this.ambiguousFilenames = findDuplicates( - this.files.map((file) => file.filename), - ); - return this.symbols; + if (shouldReload) { + this.reload(); + } + this.previousNames = names; } displaySymbols( query: string, getSymbols: (query: string) => Promise, ) { + let didQueryChange = true; this.currentQuery = query; /** @@ -300,14 +349,8 @@ class SymbolDataProvider implements TreeDataProvider { } const symbols = await getSymbols(query) ?? []; - const displayedSymbols = this.setSymbols(symbols); - - if (displayedSymbols.size) { - this.headerMessage = `Results for '${query}':`; - } else { - this.headerMessage = `No results found for '${query}'.`; - } - this.reload(); + this.setSymbols(symbols, didQueryChange); + didQueryChange = false; }; updateSymbols(); @@ -337,11 +380,7 @@ class SymbolDataProvider implements TreeDataProvider { } getTreeItem(element: Element) { - const options: ElementConversionOptions = { - shouldDisambiguate: element instanceof File && - this.ambiguousFilenames.includes(element.filename), - }; - return element.toTreeItem(options); + return element.toTreeItem(); } } From f609817d5bd06a85e3bbc6257a75398b664071de Mon Sep 17 00:00:00 2001 From: Santiago Date: Thu, 16 Jun 2022 16:40:25 -0500 Subject: [PATCH 02/13] Define the sidebar Put placeholder text field in the right location Provide an explanation of the refresh button --- deno.novaextension/extension.json | 39 ++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/deno.novaextension/extension.json b/deno.novaextension/extension.json index 594339e..d42a4ce 100644 --- a/deno.novaextension/extension.json +++ b/deno.novaextension/extension.json @@ -142,12 +142,12 @@ { "id": "co.gwil.deno.sidebars.symbols", "name": "Deno Symbols", - "placeholderText": "Use the Find Symbol command to find symbols with Deno.", "largeImage": "sidebar_large", "smallImage": "sidebar_small", "sections": [ { "id": "co.gwil.deno.sidebars.symbols.sections.1", + "placeholderText": "Use the Find Symbol command to find symbols with Deno.", "name": "Symbols", "headerCommands": [ { @@ -157,6 +157,43 @@ ] } ] + }, + { + "id": "co.gwil.deno.sidebars.tests", + "name": "Deno Tests", + "largeImage": "test_sidebar_large", + "smallImage": "test_sidebar_small", + "sections": [ + { + "id": "co.gwil.deno.sidebars.tests.sections.1", + "placeholderText": "No tests were found.", + "name": "Tests", + "headerCommands": [ + { + "title": "Learn more", + "command": "co.gwil.deno.sidebars.tests.commands.learn" + }, + { + "title": "Refresh", + "tooltip": "Check for the addition or removal of test files", + "command": "co.gwil.deno.sidebars.tests.commands.refresh", + "image": "__builtin.refresh" + }, + { + "title": "Run All", + "command": "co.gwil.deno.sidebars.tests.commands.runAll", + "image": "run" + } + ], + "contextCommands": [ + { + "title": "Run", + "command": "co.gwil.deno.sidebars.tests.commands.run", + "when": "viewItem == \"file\"" + } + ] + } + ] } ], From f2d6f90b84cce5a75d2d2add42ece5b54ac2f01b Mon Sep 17 00:00:00 2001 From: Santiago Date: Thu, 16 Jun 2022 22:25:17 -0500 Subject: [PATCH 03/13] Populate the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit includes a change to the `LICENSE` file, which may be undesired. Add indicators of whether each test passed I can't get identifiers to work 🙃 Don't show confusing expansion arrow Document mysterious code Correctly handle files with a single test Also, notify the user of empty files. Try to use nicer colors Watch file system Respect unstable API and import map preferences Don't replace sidebar after failure to parse Enable a double-click to be performed to open a test file Disable the Run button in test context menus I don't know whether the second change is necessary (`TestsDataProvider.ts`). --- LICENSE | 1 + deno.novaextension/extension.json | 2 +- src/commands/tests.sidebar.ts | 106 ++++++++++++ src/nova_deno.ts | 3 + src/sidebars.ts | 22 +++ src/tests/TestsDataProvider.ts | 272 ++++++++++++++++++++++++++++++ src/tests/register.ts | 34 ++++ 7 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 src/commands/tests.sidebar.ts create mode 100644 src/sidebars.ts create mode 100644 src/tests/TestsDataProvider.ts create mode 100644 src/tests/register.ts diff --git a/LICENSE b/LICENSE index 83fc8f8..441e021 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2021 Sam Gwilym +Copyright (c) 2015-present, Brian Woodward Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/deno.novaextension/extension.json b/deno.novaextension/extension.json index d42a4ce..f299a62 100644 --- a/deno.novaextension/extension.json +++ b/deno.novaextension/extension.json @@ -189,7 +189,7 @@ { "title": "Run", "command": "co.gwil.deno.sidebars.tests.commands.run", - "when": "viewItem == \"file\"" + "when": "viewItem == 'file'" } ] } diff --git a/src/commands/tests.sidebar.ts b/src/commands/tests.sidebar.ts new file mode 100644 index 0000000..655ae4c --- /dev/null +++ b/src/commands/tests.sidebar.ts @@ -0,0 +1,106 @@ +import { + NotificationRequest, + nova, + Process, + TextEditor, + Transferrable, + Workspace, + wrapCommand, +} from "../nova_utils.ts"; +import TestsDataProvider, { + UnexpectedLogError, +} from "../tests/TestsDataProvider.ts"; + +export function registerLearnMore() { + return nova.commands.register( + "co.gwil.deno.sidebars.tests.commands.learn", + wrapCommand(learnMore), + ); + + function learnMore() { + const options = { + args: ["https://deno.land/manual/testing#running-tests"], + }; + const process = new Process("/usr/bin/open", options); + process.start(); + } +} + +export function registerRefresh(testsDataProvider: TestsDataProvider) { + return nova.commands.register( + "co.gwil.deno.sidebars.tests.commands.refresh", + wrapCommand(refresh), + ); + + function refresh() { + if (nova.workspace.path) { + testsDataProvider.updateFiles(nova.workspace.path); + testsDataProvider.treeView.reload(); + } else { + const informativeNotificationRequest = new NotificationRequest( + "co.gwil.deno.notifications.missingWorkspacePathForTests", + ); + informativeNotificationRequest.title = + "The tests sidebar is unavailable."; + informativeNotificationRequest.body = + "Open a project in which to look for tests."; + nova.notifications.add(informativeNotificationRequest); + } + } +} + +export function registerRunAll(testsDataProvider: TestsDataProvider) { + return nova.commands.register( + "co.gwil.deno.sidebars.tests.commands.runAll", + wrapCommand(runAll), + ); + + async function runAll() { + try { + await testsDataProvider.runTests(); + testsDataProvider.treeView.reload(); + } catch (e) { + if (e instanceof UnexpectedLogError) { + const dissatisfactoryOutcomeNotificationRequest = + new NotificationRequest( + "co.gwil.deno.notifications.fragileTestsSidebar", + ); + dissatisfactoryOutcomeNotificationRequest.title = + "The tests' outcomes are unknown."; + dissatisfactoryOutcomeNotificationRequest.body = + "This feature is fragile, and relies on the extension's compatibility with your particular Deno version. If you're using an outdated version of Deno, or an outdated version of the extension, try updating."; + dissatisfactoryOutcomeNotificationRequest.actions = ["OK"]; + nova.notifications.add(dissatisfactoryOutcomeNotificationRequest); + } else { + // shown as a Nova message regardless; doesn't produce a crash + throw e; + } + } + } +} + +export function registerRun(testsDataProvider: TestsDataProvider) { + return nova.commands.register( + "co.gwil.deno.sidebars.tests.commands.run", + wrapCommand(run), + ); + + function run() { + testsDataProvider.runTests(); + } +} + +// This wrapper is used by the sidebar to enable test files to be opened by double-clicking. +export function registerOpen(testsDataProvider: TestsDataProvider) { + return nova.commands.register( + "co.gwil.deno.sidebars.tests.commands.open", + wrapCommand(open), + ); + + function open() { + const selected = testsDataProvider.treeView.selection[0]; + if (selected?.path) { + nova.workspace.openFile(selected.path); + } + } +} diff --git a/src/nova_deno.ts b/src/nova_deno.ts index 71100fe..3539292 100644 --- a/src/nova_deno.ts +++ b/src/nova_deno.ts @@ -15,6 +15,7 @@ import { CanNotEnsureError, makeClientDisposable, } from "./client_disposable.ts"; +import { registerTestsSidebar } from "./tests/register.ts"; const compositeDisposable = new CompositeDisposable(); const taskDisposable = new CompositeDisposable(); @@ -53,6 +54,8 @@ export async function activate() { compositeDisposable.add(registerEditorWatcher()); + compositeDisposable.add(registerTestsSidebar()); + const configFileWatchingDisposables = watchConfigFiles( nova.workspace.path, configFilenames, diff --git a/src/sidebars.ts b/src/sidebars.ts new file mode 100644 index 0000000..9153dbd --- /dev/null +++ b/src/sidebars.ts @@ -0,0 +1,22 @@ +import { TreeItem } from "./nova_utils.ts"; + +export interface Element { + toTreeItem: () => TreeItem; + children: Element[]; + path?: string; + uri?: string; + shouldDisambiguate?: boolean; +} + +export class Header implements Element { + content: string; + children: []; + constructor(content: string) { + this.content = content; + this.children = []; + } + + toTreeItem() { + return new TreeItem(this.content); + } +} diff --git a/src/tests/TestsDataProvider.ts b/src/tests/TestsDataProvider.ts new file mode 100644 index 0000000..9b84223 --- /dev/null +++ b/src/tests/TestsDataProvider.ts @@ -0,0 +1,272 @@ +import { + Color, + ColorComponents, + ColorFormat, // It is, actually, read. I think this message is due to a @ts-expect-error. + getOverridableBoolean, + nova, + Process, + TreeDataProvider, + TreeItem, + TreeItemCollapsibleState, + TreeView, +} from "../nova_utils.ts"; +import { Element, Header } from "../sidebars.ts"; + +class Test implements Element { + name: string; + passed: boolean; + children: []; + constructor(name: string, passed: boolean) { + this.name = name; + this.passed = passed; + this.children = []; + } + + toTreeItem() { + const item = new TreeItem(this.name); + // TODO item.identifier = + // @ts-expect-error: ColorFormat is a value —not a type. TypeScript is complaining otherwise. + const format = ColorFormat.rgb; + + const failedColorComponents: ColorComponents = [ + 0.88671875, + 0.23828125, + 0.23828125, + 1, + ]; + const passedColorComponents: ColorComponents = [ + 0.30859375, + 0.91796875, + 0.20312500, + 1, + ]; + + item.color = new Color( + format, + this.passed ? passedColorComponents : failedColorComponents, + ); + item.descriptiveText = this.passed ? "Passed" : "Failed"; + item.contextValue = "test"; + + return item; + } +} +export class UnexpectedLogError extends Error {} +export class TestFile implements Element { + path: string; + children: Element[]; + shouldDisambiguate: boolean; + + constructor(path: string, children: Element[], shouldDisambiguate: boolean) { + this.path = path; + this.children = children; + this.shouldDisambiguate = shouldDisambiguate; + } + + get filename(): string { + return nova.path.basename(this.path); + } + + get extension(): string { + return nova.path.extname(this.path); + } + + toTreeItem() { + const relativePath: string = nova.workspace.relativizePath(this.path); + const item = new TreeItem( + this.shouldDisambiguate ? relativePath : this.filename, + ); + + item.image = "__filetype" + this.extension; + item.collapsibleState = this.children.length + ? TreeItemCollapsibleState.Expanded + : TreeItemCollapsibleState.None; + item.contextValue = "file"; + item.identifier = this.path; + item.command = "co.gwil.deno.sidebars.tests.commands.open"; + + return item; + } +} + +export default class TestsDataProvider implements TreeDataProvider { + // See https://deno.land/manual/testing#running-tests. + static FILE_REGEXP = /^.*[_.]?test\.(ts|tsx|mts|js|mjs|jsx|cjs|cts)$/; + treeView: TreeView; + files: TestFile[]; + + constructor() { + this.treeView = new TreeView("co.gwil.deno.sidebars.tests.sections.1", { + dataProvider: this, + }); + this.files = []; + + if (nova.workspace.path) { + this.updateFiles(nova.workspace.path); + } + } + + static findTests(directoryPath: string): string[] { + function isAccessibleDirectory(path: string) { + try { + nova.fs.listdir(path); + } catch { + return false; + } + return true; + } + + const tests: string[] = []; + for (const name of nova.fs.listdir(directoryPath)) { + const path = nova.path.join(directoryPath, name); + if (isAccessibleDirectory(path)) { + let internalTests = null; + try { + internalTests = TestsDataProvider.findTests(path); + } catch { + continue; + } + tests.push(...internalTests); + } else if (name.match(TestsDataProvider.FILE_REGEXP)) { + tests.push(path); + } + } + + return tests; + } + + updateFiles(directoryPath: string) { + const newPaths = TestsDataProvider.findTests(directoryPath); + const existingPaths = this.files.map((file) => file.path); + + const toRemove: number[] = []; + for (let i = 0; i < existingPaths.length; i++) { + const path = existingPaths[i]; + if (!newPaths.includes(path)) { + toRemove.push(i); + } + } + this.files = this.files.filter((_value, index) => + !toRemove.includes(index) + ); + + for (const path of newPaths) { + if (!this.files.find((file) => file.path == path)) { + this.files.push( + new TestFile( + path, + [], + false, // TODO + ), + ); + } + } + + // The above process is carried out instead of replacing the `this.files` property. I prefer this because it does not remove test results from the sidebar. + } + + runTests(tests?: string[]) { + if (!nova.workspace.path) { + throw new Error("This function requires a workspace path."); + } + + const paths = tests ?? this.files.map((file) => file.path); + const args = ["test", "-A"]; + if (getOverridableBoolean("co.gwil.deno.config.enableUnstable")) { + args.push("--unstable"); + } + + const potentialImportMapLocation = nova.workspace.config.get( + "co.gwil.deno.config.import-map", + ); + if (potentialImportMapLocation) { + args.push("--import-map=" + potentialImportMapLocation); + } + + const options = { + args: ["deno", ...args, ...paths], + cwd: nova.workspace.path, + }; + const denoProcess = new Process("/usr/bin/env", options); + + // This must be very fragile! + const INTRODUCTION_REGEXP = /running (\d+) tests? from ([^]+)/; + const TEST_REGEXP = /([^]+) \.\.\. (FAILED|ok) \(\d+[A-Za-z]+\)$/; + const CONTROL_REGEXP = + // deno-lint-ignore no-control-regex + /[\u001b\u009b][[\]#;?()]*(?:(?:(?:[^\W_]*;?[^\W_]*)\u0007)|(?:(?:[0-9]{1,4}(;[0-9]{0,4})*)?[~0-9=<>cf-nqrtyA-PRZ]))/g; + + const output: TestFile[] = []; + + let loggingError: UnexpectedLogError | null = null; + denoProcess.onStdout((line) => { + // remove newline + line = line.slice(0, -1); + console.log(line); + // remove control (?) characters that make output colorful + line = line.replace( + CONTROL_REGEXP, + "", + ); + const introduction = line.match(INTRODUCTION_REGEXP); + const test = line.match(TEST_REGEXP); + + if (introduction) { + const [, count, relativePath] = introduction; + output.push( + new TestFile( + nova.path.normalize( + nova.path.join(nova.workspace.path!, relativePath), + ), + count == "0" ? [new Header("No tests")] : [], + false, // TODO + ), + ); + } else if (test) { + const [, name, message] = test; + const passed = message == "ok"; + + const currentFile = output.at(-1); + if (!currentFile) { + if (!loggingError) { + loggingError = new UnexpectedLogError( + "Unexpected logging. Oops.", + ); + } + return; + } + currentFile.children.push(new Test(name, passed)); + } + }); + + const onExit = new Promise((resolve, reject) => { + denoProcess.onDidExit(() => { + // TODO: explore the dangers regarding tests that take long to execute + if (loggingError) { + reject(loggingError); + } else { + this.files = output; + resolve(output); + } + }); + }); + + denoProcess.start(); + return onExit; + } + + getChildren(element: Element | null): Element[] { + if (element == null) { + if (nova.workspace.path) { + return this.files; + } else { + return [new Header("Open a Deno project to see test results.")]; + } + } + return element.children; + } + + getTreeItem(element: Element): TreeItem { + return element.toTreeItem(); + } +} diff --git a/src/tests/register.ts b/src/tests/register.ts new file mode 100644 index 0000000..fa8a25c --- /dev/null +++ b/src/tests/register.ts @@ -0,0 +1,34 @@ +import { CompositeDisposable, nova } from "../nova_utils.ts"; +import { + registerLearnMore, + registerOpen, + registerRefresh, + registerRun, + registerRunAll, +} from "../commands/tests.sidebar.ts"; +import TestsDataProvider from "./TestsDataProvider.ts"; + +export function registerTestsSidebar() { + const testsDisposable = new CompositeDisposable(); + + const dataProvider = new TestsDataProvider(); + testsDisposable.add(dataProvider.treeView); + + // It seems that Nova won't accept a more narrow glob, like the one in the Deno documentation. + // https://deno.land/manual/testing#running-tests + const GLOB = "*test.*"; + testsDisposable.add( + nova.fs.watch(GLOB, () => { + dataProvider.updateFiles(nova.workspace.path!); + dataProvider.treeView.reload(); + }), + ); + + testsDisposable.add(registerLearnMore()); + testsDisposable.add(registerRefresh(dataProvider)); + testsDisposable.add(registerRun(dataProvider)); + testsDisposable.add(registerRunAll(dataProvider)); + testsDisposable.add(registerOpen(dataProvider)); + + return testsDisposable; +} From b4fb17a215a0eb2e034303e0b9b5c55922a3f90b Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 19 Jun 2022 16:07:53 -0500 Subject: [PATCH 04/13] Refactor Find Symbol to match the new tests sidebar --- src/client_disposable.ts | 9 +-- src/commands/palette_find_symbol.ts | 26 ------ src/commands/symbols.sidebar.ts | 81 +++++++++++++++++++ .../SymbolDataProvider.ts} | 53 +----------- src/find_symbol/register.ts | 18 +++++ 5 files changed, 103 insertions(+), 84 deletions(-) delete mode 100644 src/commands/palette_find_symbol.ts create mode 100644 src/commands/symbols.sidebar.ts rename src/{commands/sidebar_find_symbol.ts => find_symbol/SymbolDataProvider.ts} (85%) create mode 100644 src/find_symbol/register.ts diff --git a/src/client_disposable.ts b/src/client_disposable.ts index 0bd28db..9cf7535 100644 --- a/src/client_disposable.ts +++ b/src/client_disposable.ts @@ -10,8 +10,7 @@ import { import registerFormatDocument from "./commands/format_document.ts"; import registerCache from "./commands/cache.ts"; import registerRenameSymbol from "./commands/rename_symbol.ts"; -import registerPaletteFindSymbol from "./commands/palette_find_symbol.ts"; -import registerSymbolSidebarFindSymbol from "./commands/sidebar_find_symbol.ts"; +import registerFindSymbol from "./find_symbol/register.ts"; import syntaxes from "./syntaxes.ts"; const FORMAT_ON_SAVE_CONFIG_KEY = "co.gwil.deno.config.formatOnSave"; @@ -140,11 +139,7 @@ export async function makeClientDisposable( clientDisposable.add(registerFormatDocument(client)); clientDisposable.add(registerCache(client)); clientDisposable.add(registerRenameSymbol(client)); - - // palette Find Symbol command - clientDisposable.add(registerPaletteFindSymbol()); - // sidebar Find Symbol command - clientDisposable.add(registerSymbolSidebarFindSymbol(client)); + clientDisposable.add(registerFindSymbol(client)); nova.workspace.onDidAddTextEditor((editor) => { const editorDisposable = new CompositeDisposable(); diff --git a/src/commands/palette_find_symbol.ts b/src/commands/palette_find_symbol.ts deleted file mode 100644 index c23803c..0000000 --- a/src/commands/palette_find_symbol.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NotificationRequest, nova, wrapCommand } from "../nova_utils.ts"; - -export default function registerFindSymbol() { - return nova.commands.register( - "co.gwil.deno.commands.find", - wrapCommand(maybeFindSymbol), - ); - - async function maybeFindSymbol() { - try { - await nova.commands.invoke("co.gwil.deno.sidebars.symbols.commands.find"); - } catch (err) { - // I don't think this ever happens. - console.error(err); - } - - const sidebarReminderNotificationRequest = new NotificationRequest( - "co.gwil.deno.notifications.checkTheSidebar", - ); - sidebarReminderNotificationRequest.title = - "Symbols are shown in the Deno Symbols sidebar."; - sidebarReminderNotificationRequest.body = - "To see your search's results, check the Deno Symbols sidebar."; - nova.notifications.add(sidebarReminderNotificationRequest); - } -} diff --git a/src/commands/symbols.sidebar.ts b/src/commands/symbols.sidebar.ts new file mode 100644 index 0000000..fd1b074 --- /dev/null +++ b/src/commands/symbols.sidebar.ts @@ -0,0 +1,81 @@ +import { + LanguageClient, + NotificationRequest, + nova, + wrapCommand, +} from "../nova_utils.ts"; +import SymbolDataProvider from "../find_symbol/SymbolDataProvider.ts"; +import { lsp } from "../../deps.ts"; + +export function registerPaletteFindSymbol() { + return nova.commands.register( + "co.gwil.deno.commands.find", + wrapCommand(maybeFindSymbol), + ); + + async function maybeFindSymbol() { + try { + await nova.commands.invoke("co.gwil.deno.sidebars.symbols.commands.find"); + } catch (err) { + // I don't think this ever happens. + console.error(err); + } + + const sidebarReminderNotificationRequest = new NotificationRequest( + "co.gwil.deno.notifications.checkTheSidebar", + ); + sidebarReminderNotificationRequest.title = + "Symbols are shown in the Deno Symbols sidebar."; + sidebarReminderNotificationRequest.body = + "To see your search's results, check the Deno Symbols sidebar."; + nova.notifications.add(sidebarReminderNotificationRequest); + } +} + +export function registerSidebarFindSymbol( + client: LanguageClient, + symbolDataProvider: SymbolDataProvider, +) { + return nova.commands.register( + "co.gwil.deno.sidebars.symbols.commands.find", + wrapCommand(findSymbol), + ); + + async function findSymbol() { + if ( + // @ts-expect-error: The Nova types are outdated. + !(nova.workspace.context as Configuration).get("shouldDisplayFeatures") + ) { + const failureNotificationReq = new NotificationRequest( + "co.gwil.deno.notifications.findSymbolUnavailable", + ); + failureNotificationReq.title = "Find Symbol is unavailable."; + failureNotificationReq.body = + "Open a TypeScript, JavaScript, JSX or TSX file."; + nova.notifications.add(failureNotificationReq); + return; + } + + const query = await new Promise((resolve) => + nova.workspace.showInputPalette( + "Type the name of a variable, class or function.", + {}, + resolve, + ) + ) as string | null | undefined; + + // This happens if the user exits the palette, for example, by pressing Escape. + if (!query) return; + + symbolDataProvider.displaySymbols(query, getSymbols); + + async function getSymbols(query: string) { + const params = { query }; + const response = await client.sendRequest( + "workspace/symbol", + params, + ) as lsp.SymbolInformation[] | null; + return response; + } + } +} diff --git a/src/commands/sidebar_find_symbol.ts b/src/find_symbol/SymbolDataProvider.ts similarity index 85% rename from src/commands/sidebar_find_symbol.ts rename to src/find_symbol/SymbolDataProvider.ts index 8acd3fe..cf9634f 100644 --- a/src/commands/sidebar_find_symbol.ts +++ b/src/find_symbol/SymbolDataProvider.ts @@ -204,8 +204,8 @@ class Symbol implements Element { editor.scrollToPosition(range.start); } } -class SymbolDataProvider implements TreeDataProvider { - private treeView: TreeView; +export default class SymbolDataProvider implements TreeDataProvider { + treeView: TreeView; // The locations need to be stored separately to enable the sidebar to reload infrequently. private locations: Map; @@ -383,52 +383,3 @@ class SymbolDataProvider implements TreeDataProvider { return element.toTreeItem(); } } - -export default function registerFindSymbol(client: LanguageClient) { - return nova.commands.register( - "co.gwil.deno.sidebars.symbols.commands.find", - wrapCommand(findSymbol), - ); - - async function findSymbol() { - if (!symbolDataProvider) { - symbolDataProvider = new SymbolDataProvider(); - } - - if ( - // @ts-expect-error: The Nova types are outdated. - !(nova.workspace.context as Configuration).get("shouldDisplayFeatures") - ) { - const failureNotificationReq = new NotificationRequest( - "co.gwil.deno.notifications.findSymbolUnavailable", - ); - failureNotificationReq.title = "Find Symbol is unavailable."; - failureNotificationReq.body = - "Open a TypeScript, JavaScript, JSX or TSX file."; - nova.notifications.add(failureNotificationReq); - return; - } - - const query = await new Promise((resolve) => - nova.workspace.showInputPalette( - "Type the name of a variable, class or function.", - {}, - resolve, - ) - ) as string | null | undefined; - - // This happens if the user exits the palette, for example, by pressing Escape. - if (!query) return; - - symbolDataProvider.displaySymbols(query, getSymbols); - - async function getSymbols(query: string) { - const params = { query }; - const response = await client.sendRequest( - "workspace/symbol", - params, - ) as lsp.SymbolInformation[] | null; - return response; - } - } -} diff --git a/src/find_symbol/register.ts b/src/find_symbol/register.ts new file mode 100644 index 0000000..9105e97 --- /dev/null +++ b/src/find_symbol/register.ts @@ -0,0 +1,18 @@ +import { CompositeDisposable, LanguageClient } from "../nova_utils.ts"; +import { + registerPaletteFindSymbol, + registerSidebarFindSymbol, +} from "../commands/symbols.sidebar.ts"; +import SymbolDataProvider from "./SymbolDataProvider.ts"; + +export default function registerSymbolsSidebar(client: LanguageClient) { + const symbolsDisposable = new CompositeDisposable(); + + const dataProvider = new SymbolDataProvider(); + symbolsDisposable.add(dataProvider.treeView); + + symbolsDisposable.add(registerSidebarFindSymbol(client, dataProvider)); + symbolsDisposable.add(registerPaletteFindSymbol()); + + return symbolsDisposable; +} From 74f1669f45414f5326045cad32ffb8c08f7a2cef Mon Sep 17 00:00:00 2001 From: Santiago Date: Mon, 20 Jun 2022 11:15:59 -0500 Subject: [PATCH 05/13] Remove duplicate code --- src/find_symbol/SymbolDataProvider.ts | 24 +----------------------- src/sidebars.ts | 3 --- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/src/find_symbol/SymbolDataProvider.ts b/src/find_symbol/SymbolDataProvider.ts index cf9634f..4ca0b9b 100644 --- a/src/find_symbol/SymbolDataProvider.ts +++ b/src/find_symbol/SymbolDataProvider.ts @@ -1,27 +1,18 @@ import { - Configuration, Disposable, - LanguageClient, lspRangeToRange, - NotificationRequest, nova, TreeDataProvider, TreeItem, TreeItemCollapsibleState, TreeView, - wrapCommand, } from "../nova_utils.ts"; import { lsp } from "../../deps.ts"; -let symbolDataProvider: SymbolDataProvider | null = null; +import { Element, Header } from "../sidebars.ts"; function getFilename(uri: string) { return decodeURIComponent(uri).split("/").pop()!; } - -interface Element { - toTreeItem: () => TreeItem; - children: Element[]; -} class File implements Element { uri: string; extension: string; @@ -51,19 +42,6 @@ class File implements Element { return item; } } -class Header implements Element { - content: string; - children: []; - constructor(content: string) { - this.content = content; - this.children = []; - } - - toTreeItem() { - const item = new TreeItem(this.content); - return item; - } -} class Symbol implements Element { name: string; type: string; diff --git a/src/sidebars.ts b/src/sidebars.ts index 9153dbd..d844431 100644 --- a/src/sidebars.ts +++ b/src/sidebars.ts @@ -3,9 +3,6 @@ import { TreeItem } from "./nova_utils.ts"; export interface Element { toTreeItem: () => TreeItem; children: Element[]; - path?: string; - uri?: string; - shouldDisambiguate?: boolean; } export class Header implements Element { From b67e93858009de2eb65f0a82d9ec8d97241ea768 Mon Sep 17 00:00:00 2001 From: Santiago Date: Mon, 20 Jun 2022 21:41:23 -0500 Subject: [PATCH 06/13] Enable all permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This includes highly precise measuring of time, I think, as well as loading dynamic libraries. Remove VS Code settings folder I swear I'm not a traitor! I just use the thing sometimes 😊. Remove aosdijfoaisjdf.test.ts This is what I get for git commit -a…ing. --- src/commands/tests.sidebar.ts | 64 ++++++++++++++++++++++++++++++++++ src/tests/TestsDataProvider.ts | 23 +++--------- 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/src/commands/tests.sidebar.ts b/src/commands/tests.sidebar.ts index 655ae4c..cdafbaa 100644 --- a/src/commands/tests.sidebar.ts +++ b/src/commands/tests.sidebar.ts @@ -1,4 +1,5 @@ import { + FileTextMode, NotificationRequest, nova, Process, @@ -56,6 +57,69 @@ export function registerRunAll(testsDataProvider: TestsDataProvider) { ); async function runAll() { + const hiddenWorkspaceDataPath = nova.path.join( + nova.extension.workspaceStoragePath, + "state.json", + ); + + console.log(hiddenWorkspaceDataPath); + + function warningWasShown(path: string): boolean { + try { + const file = nova.fs.open(path) as FileTextMode; + const data = JSON.parse(file.readlines().join("\n")); + file.close(); + return data.warningWasShown; + } catch { + // I hope it's OK to create so many `File` objects. + try { + nova.fs.mkdir(nova.extension.workspaceStoragePath); + } catch (e) { + throw new Error( + "Could not access the extension's workspace storage path.", + { cause: e }, + ); + } + nova.fs.open(path, "x").close(); + console.log("hi"); + const file = nova.fs.open(path, "w"); + console.log("hii"); + file.write(JSON.stringify({ warningWasShown: false })); + console.log("hiii"); + file.close(); + return warningWasShown(path); + } + } + + if (!warningWasShown(hiddenWorkspaceDataPath)) { + const permissionsNotificationRequest = new NotificationRequest( + "co.gwil.deno.notifications.findSymbolUnavailable", + ); + permissionsNotificationRequest.title = + "Tests are awarded all permissions."; + permissionsNotificationRequest.body = + "Test files may access environment variables, load dynamic libraries, measure time in high resolution, utilize the network, read files and write files. This is not configurable at the moment."; + permissionsNotificationRequest.actions = ["Cancel", "Allow"]; + + const response = await nova.notifications.add( + permissionsNotificationRequest, + ); + if (response.actionIdx == 0) { + return; + } + + const oldFile = nova.fs.open(hiddenWorkspaceDataPath) as FileTextMode; + const data = JSON.parse(oldFile.readlines().join("\n")); + console.log(JSON.stringify(data)); + oldFile.close(); + nova.fs.remove(hiddenWorkspaceDataPath); + nova.fs.open(hiddenWorkspaceDataPath, "x").close(); + const file = nova.fs.open(hiddenWorkspaceDataPath, "w"); + data.warningWasShown = true; + file.write(JSON.stringify(data)); + file.close(); + } + try { await testsDataProvider.runTests(); testsDataProvider.treeView.reload(); diff --git a/src/tests/TestsDataProvider.ts b/src/tests/TestsDataProvider.ts index 9b84223..cd1b668 100644 --- a/src/tests/TestsDataProvider.ts +++ b/src/tests/TestsDataProvider.ts @@ -2,7 +2,6 @@ import { Color, ColorComponents, ColorFormat, // It is, actually, read. I think this message is due to a @ts-expect-error. - getOverridableBoolean, nova, Process, TreeDataProvider, @@ -46,7 +45,6 @@ class Test implements Element { this.passed ? passedColorComponents : failedColorComponents, ); item.descriptiveText = this.passed ? "Passed" : "Failed"; - item.contextValue = "test"; return item; } @@ -83,8 +81,6 @@ export class TestFile implements Element { : TreeItemCollapsibleState.None; item.contextValue = "file"; item.identifier = this.path; - item.command = "co.gwil.deno.sidebars.tests.commands.open"; - return item; } } @@ -165,26 +161,15 @@ export default class TestsDataProvider implements TreeDataProvider { // The above process is carried out instead of replacing the `this.files` property. I prefer this because it does not remove test results from the sidebar. } - runTests(tests?: string[]) { + runTests() { if (!nova.workspace.path) { throw new Error("This function requires a workspace path."); } - const paths = tests ?? this.files.map((file) => file.path); - const args = ["test", "-A"]; - if (getOverridableBoolean("co.gwil.deno.config.enableUnstable")) { - args.push("--unstable"); - } - - const potentialImportMapLocation = nova.workspace.config.get( - "co.gwil.deno.config.import-map", - ); - if (potentialImportMapLocation) { - args.push("--import-map=" + potentialImportMapLocation); - } + const paths = this.files.map((file) => file.path); const options = { - args: ["deno", ...args, ...paths], + args: ["deno", "test", "-A", ...paths], cwd: nova.workspace.path, }; const denoProcess = new Process("/usr/bin/env", options); @@ -242,10 +227,10 @@ export default class TestsDataProvider implements TreeDataProvider { const onExit = new Promise((resolve, reject) => { denoProcess.onDidExit(() => { // TODO: explore the dangers regarding tests that take long to execute + this.files = output; if (loggingError) { reject(loggingError); } else { - this.files = output; resolve(output); } }); From bc1253c3e283733246c96357e7da00e8cb9c8599 Mon Sep 17 00:00:00 2001 From: Santiago Date: Tue, 21 Jun 2022 20:01:31 -0500 Subject: [PATCH 07/13] Attempt to improve error detection Update message for accuracy Remove debug logging Remove unnecessary logging Suggest to users that Deno may be failing to run tests Provide feedback even when tests take long --- src/commands/tests.sidebar.ts | 21 +++++++----- src/sidebars.ts | 3 ++ src/tests/TestsDataProvider.ts | 63 ++++++++++++++++++++++++++++++---- 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/src/commands/tests.sidebar.ts b/src/commands/tests.sidebar.ts index cdafbaa..fbd142c 100644 --- a/src/commands/tests.sidebar.ts +++ b/src/commands/tests.sidebar.ts @@ -3,9 +3,6 @@ import { NotificationRequest, nova, Process, - TextEditor, - Transferrable, - Workspace, wrapCommand, } from "../nova_utils.ts"; import TestsDataProvider, { @@ -62,8 +59,6 @@ export function registerRunAll(testsDataProvider: TestsDataProvider) { "state.json", ); - console.log(hiddenWorkspaceDataPath); - function warningWasShown(path: string): boolean { try { const file = nova.fs.open(path) as FileTextMode; @@ -81,11 +76,8 @@ export function registerRunAll(testsDataProvider: TestsDataProvider) { ); } nova.fs.open(path, "x").close(); - console.log("hi"); const file = nova.fs.open(path, "w"); - console.log("hii"); file.write(JSON.stringify({ warningWasShown: false })); - console.log("hiii"); file.close(); return warningWasShown(path); } @@ -110,7 +102,6 @@ export function registerRunAll(testsDataProvider: TestsDataProvider) { const oldFile = nova.fs.open(hiddenWorkspaceDataPath) as FileTextMode; const data = JSON.parse(oldFile.readlines().join("\n")); - console.log(JSON.stringify(data)); oldFile.close(); nova.fs.remove(hiddenWorkspaceDataPath); nova.fs.open(hiddenWorkspaceDataPath, "x").close(); @@ -120,6 +111,16 @@ export function registerRunAll(testsDataProvider: TestsDataProvider) { file.close(); } + const timeoutID = setTimeout(() => { + const stillRunningNotificationRequest = new NotificationRequest( + "co.gwil.deno.notifications.runningTests", + ); + stillRunningNotificationRequest.title = "The tests are still being run."; + stillRunningNotificationRequest.body = + "It's just taking a while. Please wait."; + nova.notifications.add(stillRunningNotificationRequest); + }, 3 * 1000); + try { await testsDataProvider.runTests(); testsDataProvider.treeView.reload(); @@ -139,6 +140,8 @@ export function registerRunAll(testsDataProvider: TestsDataProvider) { // shown as a Nova message regardless; doesn't produce a crash throw e; } + } finally { + clearTimeout(timeoutID); } } } diff --git a/src/sidebars.ts b/src/sidebars.ts index d844431..9153dbd 100644 --- a/src/sidebars.ts +++ b/src/sidebars.ts @@ -3,6 +3,9 @@ import { TreeItem } from "./nova_utils.ts"; export interface Element { toTreeItem: () => TreeItem; children: Element[]; + path?: string; + uri?: string; + shouldDisambiguate?: boolean; } export class Header implements Element { diff --git a/src/tests/TestsDataProvider.ts b/src/tests/TestsDataProvider.ts index cd1b668..958f328 100644 --- a/src/tests/TestsDataProvider.ts +++ b/src/tests/TestsDataProvider.ts @@ -2,6 +2,8 @@ import { Color, ColorComponents, ColorFormat, // It is, actually, read. I think this message is due to a @ts-expect-error. + getOverridableBoolean, + NotificationRequest, nova, Process, TreeDataProvider, @@ -45,6 +47,7 @@ class Test implements Element { this.passed ? passedColorComponents : failedColorComponents, ); item.descriptiveText = this.passed ? "Passed" : "Failed"; + item.contextValue = "test"; return item; } @@ -81,6 +84,8 @@ export class TestFile implements Element { : TreeItemCollapsibleState.None; item.contextValue = "file"; item.identifier = this.path; + item.command = "co.gwil.deno.sidebars.tests.commands.open"; + return item; } } @@ -161,15 +166,26 @@ export default class TestsDataProvider implements TreeDataProvider { // The above process is carried out instead of replacing the `this.files` property. I prefer this because it does not remove test results from the sidebar. } - runTests() { + runTests(tests?: string[]) { if (!nova.workspace.path) { throw new Error("This function requires a workspace path."); } - const paths = this.files.map((file) => file.path); + const paths = tests ?? this.files.map((file) => file.path); + const args = ["test", "-A"]; + + if (getOverridableBoolean("co.gwil.deno.config.enableUnstable")) { + args.push("--unstable"); + } + const potentialImportMapLocation = nova.workspace.config.get( + "co.gwil.deno.config.import-map", + ); + if (potentialImportMapLocation) { + args.push("--import-map=" + potentialImportMapLocation); + } const options = { - args: ["deno", "test", "-A", ...paths], + args: ["deno", ...args, ...paths], cwd: nova.workspace.path, }; const denoProcess = new Process("/usr/bin/env", options); @@ -184,10 +200,14 @@ export default class TestsDataProvider implements TreeDataProvider { const output: TestFile[] = []; let loggingError: UnexpectedLogError | null = null; - denoProcess.onStdout((line) => { + denoProcess.onStderr((line) => { // remove newline line = line.slice(0, -1); console.log(line); + }); + denoProcess.onStdout((line) => { + line = line.slice(0, -1); + // remove control (?) characters that make output colorful line = line.replace( CONTROL_REGEXP, @@ -224,13 +244,44 @@ export default class TestsDataProvider implements TreeDataProvider { } }); - const onExit = new Promise((resolve, reject) => { + const onExit = new Promise((resolve, reject) => { denoProcess.onDidExit(() => { // TODO: explore the dangers regarding tests that take long to execute - this.files = output; if (loggingError) { reject(loggingError); } else { + for (const file of output) { + const analogousIndex = this.files.findIndex( + (oldFile) => oldFile.path == file.path, + ); + if (analogousIndex != -1) { + this.files[analogousIndex] = file; + } else { + this.files.push(file); + } + } + + const paths = output.map((file) => file.path); + const missingFiles = this.files.filter( + (file) => !paths.includes(file.path), + ); + for ( + const file of missingFiles + ) { + file.children = [new Header("Failed to run")]; + } + + if (missingFiles.length) { + const configurationErrorNotificationRequest = + new NotificationRequest( + "co.gwil.deno.notifications.unexpectedEmptiness", + ); + configurationErrorNotificationRequest.title = "Expecting more?"; + configurationErrorNotificationRequest.body = + "Deno may be failing to run some tests. Check the extension console for logging."; + nova.notifications.add(configurationErrorNotificationRequest); + } + resolve(output); } }); From f08f1bfdd1f1efc938e52ea7035660c6b07899a8 Mon Sep 17 00:00:00 2001 From: Santiago Date: Thu, 23 Jun 2022 22:11:01 -0500 Subject: [PATCH 08/13] Fix extension service crash --- src/tests/register.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/tests/register.ts b/src/tests/register.ts index fa8a25c..1a21f9f 100644 --- a/src/tests/register.ts +++ b/src/tests/register.ts @@ -16,13 +16,16 @@ export function registerTestsSidebar() { // It seems that Nova won't accept a more narrow glob, like the one in the Deno documentation. // https://deno.land/manual/testing#running-tests - const GLOB = "*test.*"; - testsDisposable.add( - nova.fs.watch(GLOB, () => { - dataProvider.updateFiles(nova.workspace.path!); - dataProvider.treeView.reload(); - }), - ); + console.log(4); + if (nova.workspace.path) { + const GLOB = "*test.*"; + testsDisposable.add( + nova.fs.watch(GLOB, () => { + dataProvider.updateFiles(nova.workspace.path!); + dataProvider.treeView.reload(); + }), + ); + } testsDisposable.add(registerLearnMore()); testsDisposable.add(registerRefresh(dataProvider)); From 7091bf1365895a950b0f29b5b41fcb241ddbdf52 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 25 Jun 2022 16:56:09 -0500 Subject: [PATCH 09/13] Update message I don't know how much better this is. --- src/tests/TestsDataProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/TestsDataProvider.ts b/src/tests/TestsDataProvider.ts index 958f328..50f232c 100644 --- a/src/tests/TestsDataProvider.ts +++ b/src/tests/TestsDataProvider.ts @@ -276,7 +276,7 @@ export default class TestsDataProvider implements TreeDataProvider { new NotificationRequest( "co.gwil.deno.notifications.unexpectedEmptiness", ); - configurationErrorNotificationRequest.title = "Expecting more?"; + configurationErrorNotificationRequest.title = "Check the console."; configurationErrorNotificationRequest.body = "Deno may be failing to run some tests. Check the extension console for logging."; nova.notifications.add(configurationErrorNotificationRequest); From 1fc0c0d653f09080f8e32e7509aea821725e5d96 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 25 Jun 2022 16:58:09 -0500 Subject: [PATCH 10/13] Don't show a special header in project-less windows The message couldn't really be read and I think the placeholder defined in the `extension.json` file is appropriate. --- src/tests/TestsDataProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/TestsDataProvider.ts b/src/tests/TestsDataProvider.ts index 50f232c..e8d9baa 100644 --- a/src/tests/TestsDataProvider.ts +++ b/src/tests/TestsDataProvider.ts @@ -296,7 +296,7 @@ export default class TestsDataProvider implements TreeDataProvider { if (nova.workspace.path) { return this.files; } else { - return [new Header("Open a Deno project to see test results.")]; + return []; } } return element.children; From d3aec98e9e84a46ce5b96e383443e732b36200ec Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 25 Jun 2022 17:23:48 -0500 Subject: [PATCH 11/13] Enable tests to be run individually --- src/commands/tests.sidebar.ts | 8 +++++-- src/tests/TestsDataProvider.ts | 41 ++++++++++++++++++---------------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/commands/tests.sidebar.ts b/src/commands/tests.sidebar.ts index fbd142c..74ab96e 100644 --- a/src/commands/tests.sidebar.ts +++ b/src/commands/tests.sidebar.ts @@ -6,6 +6,7 @@ import { wrapCommand, } from "../nova_utils.ts"; import TestsDataProvider, { + TestFile, UnexpectedLogError, } from "../tests/TestsDataProvider.ts"; @@ -152,8 +153,11 @@ export function registerRun(testsDataProvider: TestsDataProvider) { wrapCommand(run), ); - function run() { - testsDataProvider.runTests(); + async function run() { + // This command is only available when `TestFile`s, exclusively, are selected. + const selected = testsDataProvider.treeView.selection as TestFile[]; + await testsDataProvider.runTests(selected.map((file) => file.path)); + testsDataProvider.treeView.reload(); } } diff --git a/src/tests/TestsDataProvider.ts b/src/tests/TestsDataProvider.ts index e8d9baa..8d9b4e0 100644 --- a/src/tests/TestsDataProvider.ts +++ b/src/tests/TestsDataProvider.ts @@ -171,6 +171,7 @@ export default class TestsDataProvider implements TreeDataProvider { throw new Error("This function requires a workspace path."); } + const ranAll = !tests; const paths = tests ?? this.files.map((file) => file.path); const args = ["test", "-A"]; @@ -246,7 +247,6 @@ export default class TestsDataProvider implements TreeDataProvider { const onExit = new Promise((resolve, reject) => { denoProcess.onDidExit(() => { - // TODO: explore the dangers regarding tests that take long to execute if (loggingError) { reject(loggingError); } else { @@ -261,25 +261,28 @@ export default class TestsDataProvider implements TreeDataProvider { } } - const paths = output.map((file) => file.path); - const missingFiles = this.files.filter( - (file) => !paths.includes(file.path), - ); - for ( - const file of missingFiles - ) { - file.children = [new Header("Failed to run")]; - } + if (ranAll) { + const paths = output.map((file) => file.path); + const missingFiles = this.files.filter( + (file) => !paths.includes(file.path), + ); + for ( + const file of missingFiles + ) { + file.children = [new Header("Failed to run")]; + } - if (missingFiles.length) { - const configurationErrorNotificationRequest = - new NotificationRequest( - "co.gwil.deno.notifications.unexpectedEmptiness", - ); - configurationErrorNotificationRequest.title = "Check the console."; - configurationErrorNotificationRequest.body = - "Deno may be failing to run some tests. Check the extension console for logging."; - nova.notifications.add(configurationErrorNotificationRequest); + if (missingFiles.length) { + const configurationErrorNotificationRequest = + new NotificationRequest( + "co.gwil.deno.notifications.unexpectedEmptiness", + ); + configurationErrorNotificationRequest.title = + "Check the console."; + configurationErrorNotificationRequest.body = + "Deno may be failing to run some tests. Check the extension console for logging."; + nova.notifications.add(configurationErrorNotificationRequest); + } } resolve(output); From eea678c2177afc4372128fd5b017b268d71046ef Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 25 Jun 2022 17:47:51 -0500 Subject: [PATCH 12/13] Make the reading and writing of files less ugly --- src/commands/tests.sidebar.ts | 37 +++++++++++++++++------------------ 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/commands/tests.sidebar.ts b/src/commands/tests.sidebar.ts index 74ab96e..7699019 100644 --- a/src/commands/tests.sidebar.ts +++ b/src/commands/tests.sidebar.ts @@ -60,14 +60,22 @@ export function registerRunAll(testsDataProvider: TestsDataProvider) { "state.json", ); - function warningWasShown(path: string): boolean { + function readState(path: string): Record { try { const file = nova.fs.open(path) as FileTextMode; - const data = JSON.parse(file.readlines().join("\n")); + const content = file.readlines().join("\n"); file.close(); - return data.warningWasShown; + + return JSON.parse(content); + } catch { + return {}; + } + } + function writeState(path: string, content: Record) { + let file; + try { + file = nova.fs.open(path, "w") as FileTextMode; } catch { - // I hope it's OK to create so many `File` objects. try { nova.fs.mkdir(nova.extension.workspaceStoragePath); } catch (e) { @@ -76,15 +84,13 @@ export function registerRunAll(testsDataProvider: TestsDataProvider) { { cause: e }, ); } - nova.fs.open(path, "x").close(); - const file = nova.fs.open(path, "w"); - file.write(JSON.stringify({ warningWasShown: false })); - file.close(); - return warningWasShown(path); + file = nova.fs.open(path, "w") as FileTextMode; } + file.write(JSON.stringify(content)); } - if (!warningWasShown(hiddenWorkspaceDataPath)) { + const state = readState(hiddenWorkspaceDataPath); + if (!state.warningWasShown) { const permissionsNotificationRequest = new NotificationRequest( "co.gwil.deno.notifications.findSymbolUnavailable", ); @@ -101,15 +107,8 @@ export function registerRunAll(testsDataProvider: TestsDataProvider) { return; } - const oldFile = nova.fs.open(hiddenWorkspaceDataPath) as FileTextMode; - const data = JSON.parse(oldFile.readlines().join("\n")); - oldFile.close(); - nova.fs.remove(hiddenWorkspaceDataPath); - nova.fs.open(hiddenWorkspaceDataPath, "x").close(); - const file = nova.fs.open(hiddenWorkspaceDataPath, "w"); - data.warningWasShown = true; - file.write(JSON.stringify(data)); - file.close(); + state.warningWasShown = true; + writeState(hiddenWorkspaceDataPath, state); } const timeoutID = setTimeout(() => { From 03da2549a8ace0acb09533d8ad58744d787cda8c Mon Sep 17 00:00:00 2001 From: Santiago Date: Tue, 28 Jun 2022 19:17:59 -0500 Subject: [PATCH 13/13] More reliably notify of that a test couldn't be run This might be super broken, but so is my brain right now. For mysterious and supposedly unrelated reasons, I'm incredibly tired! I'm trusting that this code is useful even if it needs improvements. A lot of it assumes that the `path` property of corresponding `TestFile`s will always match, but that might not happen if certain algorithm changes are made, the consequences of which would not be able to be anticipated. --- src/tests/TestsDataProvider.ts | 41 ++++++++++++++++------------------ 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/tests/TestsDataProvider.ts b/src/tests/TestsDataProvider.ts index 8d9b4e0..6b29688 100644 --- a/src/tests/TestsDataProvider.ts +++ b/src/tests/TestsDataProvider.ts @@ -171,7 +171,6 @@ export default class TestsDataProvider implements TreeDataProvider { throw new Error("This function requires a workspace path."); } - const ranAll = !tests; const paths = tests ?? this.files.map((file) => file.path); const args = ["test", "-A"]; @@ -261,28 +260,26 @@ export default class TestsDataProvider implements TreeDataProvider { } } - if (ranAll) { - const paths = output.map((file) => file.path); - const missingFiles = this.files.filter( - (file) => !paths.includes(file.path), - ); - for ( - const file of missingFiles - ) { - file.children = [new Header("Failed to run")]; - } + const newPaths = output.map((file) => file.path); + const missingFiles = this.files.filter( + (file) => + paths.includes(file.path) && !newPaths.includes(file.path), + ); + for ( + const file of missingFiles + ) { + file.children = [new Header("Failed to run")]; + } - if (missingFiles.length) { - const configurationErrorNotificationRequest = - new NotificationRequest( - "co.gwil.deno.notifications.unexpectedEmptiness", - ); - configurationErrorNotificationRequest.title = - "Check the console."; - configurationErrorNotificationRequest.body = - "Deno may be failing to run some tests. Check the extension console for logging."; - nova.notifications.add(configurationErrorNotificationRequest); - } + if (missingFiles.length) { + const configurationErrorNotificationRequest = + new NotificationRequest( + "co.gwil.deno.notifications.unexpectedEmptiness", + ); + configurationErrorNotificationRequest.title = "Check the console."; + configurationErrorNotificationRequest.body = + "Deno may be failing to run some tests. Check the extension console for logging."; + nova.notifications.add(configurationErrorNotificationRequest); } resolve(output);