diff --git a/package-lock.json b/package-lock.json index 4f252276..4df72b0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-db2i", - "version": "1.13.3", + "version": "1.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-db2i", - "version": "1.13.3", + "version": "1.14.0", "dependencies": { "@ibm/mapepire-js": "^0.5.0", "@octokit/rest": "^21.1.1", diff --git a/package.json b/package.json index 6acf73f2..118060a2 100644 --- a/package.json +++ b/package.json @@ -255,6 +255,21 @@ } } }, + { + "id": "vscode-db2i.examples", + "title": "Examples", + "properties": { + "vscode-db2i.examples.customExampleDirectories": { + "type": "array", + "items": { + "type": "string", + "description": "The directory containing SQL example files." + }, + "markdownDescription": "Set of custom directories containing SQL example files to be shown in the `Examples` view. All SQL files in the specified directories and at most one subdirectory level deeper will be picked up.\n\nBy default, the folder name will be the category and the file name will be the name of the example. This can be customized by optionally including a comment in the file with the tags `category` and `description`.", + "default": [] + } + } + }, { "id": "vscode-db2i.syntax", "title": "SQL Syntax Options", @@ -772,6 +787,33 @@ "category": "Db2 for i (Examples)", "icon": "$(clear-all)" }, + { + "command": "vscode-db2i.examples.reload", + "title": "Refresh Examples", + "category": "Db2 for i (Examples)", + "icon": "$(refresh)" + }, + { + "command": "vscode-db2i.examples.save", + "title": "Save As New Example", + "category": "Db2 for i (Examples)", + "icon": "$(save)" + }, + { + "command": "vscode-db2i.examples.add", + "title": "Add...", + "category": "Db2 for i (Examples)" + }, + { + "command": "vscode-db2i.examples.remove", + "title": "Remove...", + "category": "Db2 for i (Examples)" + }, + { + "command": "vscode-db2i.examples.edit", + "title": "Edit Example", + "category": "Db2 for i (Examples)" + }, { "command": "vscode-db2i.notebook.fromSqlUri", "title": "Open as Notebook", @@ -930,6 +972,10 @@ "command": "vscode-db2i.jobManager.deleteConfig", "when": "never" }, + { + "command": "vscode-db2i.examples.edit", + "when": "never" + }, { "command": "vscode-db2i.notebook.fromSqlUri", "when": "never" @@ -999,13 +1045,13 @@ "when": "view == vscode-db2i.dove.node" }, { - "command": "vscode-db2i.queryHistory.clear", - "group": "navigation", + "command": "vscode-db2i.queryHistory.find", + "group": "navigation@0", "when": "view == queryHistory" }, { - "command": "vscode-db2i.queryHistory.find", - "group": "navigation", + "command": "vscode-db2i.queryHistory.clear", + "group": "navigation@1", "when": "view == queryHistory" }, { @@ -1070,12 +1116,27 @@ }, { "command": "vscode-db2i.examples.setFilter", - "group": "navigation", + "group": "navigation@0", "when": "view == exampleBrowser" }, { "command": "vscode-db2i.examples.clearFilter", - "group": "navigation", + "group": "navigation@1", + "when": "view == exampleBrowser" + }, + { + "command": "vscode-db2i.examples.save", + "group": "navigation@2", + "when": "view == exampleBrowser" + }, + { + "submenu": "vscode-db2i.customExampleDirectories", + "group": "navigation@3", + "when": "view == exampleBrowser" + }, + { + "command": "vscode-db2i.examples.reload", + "group": "navigation@4", "when": "view == exampleBrowser" } ], @@ -1248,6 +1309,11 @@ "command": "vscode-db2i.self.explainSelf", "when": "view == vscode-db2i.self.nodes && viewItem == selfCodeNode && vscode-db2i:continueExtensionActive", "group": "navigation" + }, + { + "command": "vscode-db2i.examples.edit", + "when": "view == exampleBrowser && viewItem == example.custom", + "group": "0_open" } ], "editor/title": [ @@ -1309,6 +1375,16 @@ "group": "navigation_notebook@1" } ], + "vscode-db2i.customExampleDirectories": [ + { + "command": "vscode-db2i.examples.add", + "group": "navigation@0" + }, + { + "command": "vscode-db2i.examples.remove", + "group": "navigation@1" + } + ], "notebook/toolbar": [ { "command": "vscode-db2i.notebook.exportAsHtml", @@ -1322,6 +1398,11 @@ "icon": "$(notebook-execute)", "id": "sql/editor/context", "label": "Run SQL statement" + }, + { + "id": "vscode-db2i.customExampleDirectories", + "label": "Custom Example Directories", + "icon": "$(folder-library)" } ], "keybindings": [ diff --git a/src/notebooks/logic/statement.ts b/src/notebooks/logic/statement.ts index 7a33d88f..aecae4db 100644 --- a/src/notebooks/logic/statement.ts +++ b/src/notebooks/logic/statement.ts @@ -1,57 +1,58 @@ -import { ChartDetail, chartTypes } from "./chart"; -import { ChartJsType, chartJsTypes } from "./chartJs"; - -export interface StatementSettings { - chart?: ChartJsType; - title?: string; - y?: string; - hideStatement?: string; - [key: string]: string -}; - -export function getStatementDetail(content: string, eol: string) { - let chartDetail: ChartDetail = {}; - let settings: StatementSettings = {}; - - // Strip out starting comments - if (content.startsWith(`--`)) { - const lines = content.split(eol); - const firstNonCommentLine = lines.findIndex(line => !line.startsWith(`--`)); - - const startingComments = lines.slice(0, firstNonCommentLine).map(line => line.substring(2).trim()); - content = lines.slice(firstNonCommentLine).join(eol); - - for (let comment of startingComments) { - const sep = comment.indexOf(`:`); - const key = comment.substring(0, sep).trim(); - const value = comment.substring(sep + 1).trim(); - settings[key] = value; - } - - // Chart settings defined by comments - if (settings[`chart`] && chartJsTypes.includes(settings[`chart`])) { - chartDetail.type = settings[`chart`]; - } - - if (settings[`title`]) { - chartDetail.title = settings[`title`]; - } - - if (settings[`y`]) { - chartDetail.y = settings[`y`]; - } - } - - // Remove trailing semicolon. The Service Component doesn't like it. - if (content.endsWith(`;`)) { - content = content.substring(0, content.length - 1); - } - - // Perhaps the chart type is defined by the statement prefix - const chartType: ChartJsType | undefined = chartTypes.find(type => content.startsWith(`${type}:`)); - if (chartType) { - chartDetail.type = chartType; - content = content.substring(chartType.length + 1); - } - return { chartDetail, content, settings }; +import { ChartDetail, chartTypes } from "./chart"; +import { ChartJsType, chartJsTypes } from "./chartJs"; + +export interface StatementSettings { + chart?: ChartJsType; + title?: string; + y?: string; + hideStatement?: string; + [key: string]: string +}; + +export function getStatementDetail(content: string, eol: string) { + let chartDetail: ChartDetail = {}; + let settings: StatementSettings = {}; + + // Strip out starting comments + if (content.startsWith(`--`)) { + const lines = content.split(eol); + const firstNonCommentLine = lines.findIndex(line => !line.startsWith(`--`)); + + const startingCommentLines = firstNonCommentLine === -1 ? lines : lines.slice(0, firstNonCommentLine); + const startingComments = startingCommentLines.map(line => line.substring(2).trim()); + content = lines.slice(firstNonCommentLine).join(eol); + + for (let comment of startingComments) { + const sep = comment.indexOf(`:`); + const key = comment.substring(0, sep).trim(); + const value = comment.substring(sep + 1).trim(); + settings[key] = value; + } + + // Chart settings defined by comments + if (settings[`chart`] && chartJsTypes.includes(settings[`chart`])) { + chartDetail.type = settings[`chart`]; + } + + if (settings[`title`]) { + chartDetail.title = settings[`title`]; + } + + if (settings[`y`]) { + chartDetail.y = settings[`y`]; + } + } + + // Remove trailing semicolon. The Service Component doesn't like it. + if (content.endsWith(`;`)) { + content = content.substring(0, content.length - 1); + } + + // Perhaps the chart type is defined by the statement prefix + const chartType: ChartJsType | undefined = chartTypes.find(type => content.startsWith(`${type}:`)); + if (chartType) { + chartDetail.type = chartType; + content = content.substring(chartType.length + 1); + } + return { chartDetail, content, settings }; } \ No newline at end of file diff --git a/src/views/examples/contributes.json b/src/views/examples/contributes.json index af915ae4..cd95175e 100644 --- a/src/views/examples/contributes.json +++ b/src/views/examples/contributes.json @@ -1,42 +1,131 @@ -{ - "contributes": { - "views": { - "db2-explorer": [ - { - "id": "exampleBrowser", - "name": "Examples", - "visibility": "collapsed", - "when": "code-for-ibmi:connected == true" - } - ] - }, - "commands": [ - { - "command": "vscode-db2i.examples.setFilter", - "title": "Set filter", - "category": "Db2 for i (Examples)", - "icon": "$(filter)" - }, - { - "command": "vscode-db2i.examples.clearFilter", - "title": "Clear filter", - "category": "Db2 for i (Examples)", - "icon": "$(clear-all)" - } - ], - "menus": { - "view/title": [ - { - "command": "vscode-db2i.examples.setFilter", - "group": "navigation", - "when": "view == exampleBrowser" - }, - { - "command": "vscode-db2i.examples.clearFilter", - "group": "navigation", - "when": "view == exampleBrowser" - } - ] - } - } +{ + "contributes": { + "views": { + "db2-explorer": [ + { + "id": "exampleBrowser", + "name": "Examples", + "visibility": "collapsed", + "when": "code-for-ibmi:connected == true" + } + ] + }, + "commands": [ + { + "command": "vscode-db2i.examples.setFilter", + "title": "Set filter", + "category": "Db2 for i (Examples)", + "icon": "$(filter)" + }, + { + "command": "vscode-db2i.examples.clearFilter", + "title": "Clear filter", + "category": "Db2 for i (Examples)", + "icon": "$(clear-all)" + }, + { + "command": "vscode-db2i.examples.reload", + "title": "Refresh Examples", + "category": "Db2 for i (Examples)", + "icon": "$(refresh)" + }, + { + "command": "vscode-db2i.examples.save", + "title": "Save As New Example", + "category": "Db2 for i (Examples)", + "icon": "$(save)" + }, + { + "command": "vscode-db2i.examples.add", + "title": "Add...", + "category": "Db2 for i (Examples)" + }, + { + "command": "vscode-db2i.examples.remove", + "title": "Remove...", + "category": "Db2 for i (Examples)" + }, + { + "command": "vscode-db2i.examples.edit", + "title": "Edit Example", + "category": "Db2 for i (Examples)" + } + ], + "submenus": [ + { + "id": "vscode-db2i.customExampleDirectories", + "label": "Custom Example Directories", + "icon": "$(folder-library)" + } + ], + "menus": { + "view/title": [ + { + "command": "vscode-db2i.examples.setFilter", + "group": "navigation@0", + "when": "view == exampleBrowser" + }, + { + "command": "vscode-db2i.examples.clearFilter", + "group": "navigation@1", + "when": "view == exampleBrowser" + }, + { + "command": "vscode-db2i.examples.save", + "group": "navigation@2", + "when": "view == exampleBrowser" + }, + { + "submenu": "vscode-db2i.customExampleDirectories", + "group": "navigation@3", + "when": "view == exampleBrowser" + }, + { + "command": "vscode-db2i.examples.reload", + "group": "navigation@4", + "when": "view == exampleBrowser" + } + ], + "view/item/context": [ + { + "command": "vscode-db2i.examples.edit", + "when": "view == exampleBrowser && viewItem == example.custom", + "group": "0_open" + } + ], + "vscode-db2i.customExampleDirectories": [ + { + "command": "vscode-db2i.examples.add", + "group": "navigation@0" + }, + { + "command": "vscode-db2i.examples.remove", + "group": "navigation@1" + } + ], + "commandPalette": [ + { + "command": "vscode-db2i.examples.edit", + "when": "never" + } + ] + }, + "configuration": [ + { + "id": "vscode-db2i.examples", + "title": "Examples", + "properties": { + "vscode-db2i.examples.customExampleDirectories": { + "type": "array", + "items": { + "type": "string", + "description": "The directory containing SQL example files." + }, + "markdownDescription": "Set of custom directories containing SQL example files to be shown in the `Examples` view. All SQL files in the specified directories and at most one subdirectory level deeper will be picked up.\n\nBy default, the folder name will be the category and the file name will be the name of the example. This can be customized by optionally including a comment in the file with the tags `category` and `description`.", + "default": [] + } + } + } + ] + } } \ No newline at end of file diff --git a/src/views/examples/exampleBrowser.ts b/src/views/examples/exampleBrowser.ts index 3f6211e1..aed538a6 100644 --- a/src/views/examples/exampleBrowser.ts +++ b/src/views/examples/exampleBrowser.ts @@ -1,139 +1,275 @@ -import { Event, EventEmitter, ExtensionContext, MarkdownString, ThemeIcon, TreeDataProvider, TreeItem, TreeItemCollapsibleState, Uri, commands, window, workspace } from "vscode"; -import { Examples, SQLExample, ServiceInfoLabel } from "."; -import { getServiceInfo } from "../../database/serviceInfo"; -import { notebookFromStatements } from "../../notebooks/logic/openAsNotebook"; -import { osDetail } from "../../config"; - -export const openExampleCommand = `vscode-db2i.examples.open`; - -export class ExampleBrowser implements TreeDataProvider { - private _onDidChangeTreeData: EventEmitter = new EventEmitter(); - readonly onDidChangeTreeData: Event = this._onDidChangeTreeData.event; - - private currentFilter: string | undefined; - - constructor(context: ExtensionContext) { - context.subscriptions.push( - commands.registerCommand(openExampleCommand, (example: SQLExample) => { - if (example) { - if (example.isNotebook) { - notebookFromStatements(example.content); - } else { - workspace.openTextDocument({ - content: example.content.join(`\n`), - language: `sql` - }).then(doc => { - window.showTextDocument(doc); - }); - } - } - }), - - commands.registerCommand(`vscode-db2i.examples.setFilter`, async () => { - this.currentFilter = await window.showInputBox({ - title: `Example Filter`, - prompt: `Enter filter criteria`, - value: this.currentFilter, - }); - - this.refresh(); - }), - - commands.registerCommand(`vscode-db2i.examples.clearFilter`, async () => { - this.currentFilter = undefined; - this.refresh(); - }), - - commands.registerCommand("vscode-db2i.examples.reload", () => { - delete Examples[ServiceInfoLabel]; - this.refresh(); - }) - ); - } - - refresh() { - this._onDidChangeTreeData.fire(); - } - - getTreeItem(element: any): TreeItem | Thenable { - return element; - } - - async getChildren(element?: ExampleGroupItem): Promise { - if (element) { - return element.getChildren(); - } - else { - // Unlike the bulk of the examples which are defined in views/examples/index.ts, the services examples are retrieved dynamically - if (!Examples[ServiceInfoLabel]) { - Examples[ServiceInfoLabel] = await getServiceInfo(); - } - - if (this.currentFilter) { - // If there is a filter, then show all examples that include this criteria - const upperFilter = this.currentFilter.toUpperCase(); - return Object.values(Examples) - .flatMap(examples => examples.filter(exampleWorksForOnOS)) - .filter(example => example.name.toUpperCase().includes(upperFilter) || example.content.some(line => line.toUpperCase().includes(upperFilter))) - .sort(sort) - .map(example => new SQLExampleItem(example)); - } - else { - return Object.entries(Examples) - .sort(([name1], [name2]) => sort(name1, name2)) - .map(([name, examples]) => new ExampleGroupItem(name, examples)); - } - } - } -} - -class ExampleGroupItem extends TreeItem { - constructor(name: string, private group: SQLExample[]) { - super(name, TreeItemCollapsibleState.Collapsed); - this.iconPath = ThemeIcon.Folder; - } - - getChildren(): SQLExampleItem[] { - return this.group - .filter(example => exampleWorksForOnOS(example)) - .sort(sort) - .map(example => new SQLExampleItem(example)); - } -} - -class SQLExampleItem extends TreeItem { - constructor(example: SQLExample) { - super(example.name, TreeItemCollapsibleState.None); - this.iconPath = ThemeIcon.File; - this.resourceUri = Uri.parse('_.sql'); - this.tooltip = new MarkdownString(['```sql', example.content.join(`\n`), '```'].join(`\n`)); - - this.command = { - command: openExampleCommand, - title: `Open example`, - arguments: [example] - }; - } -} - -function exampleWorksForOnOS(example: SQLExample): boolean { - - if (osDetail) { - const myOsVersion = osDetail.getVersion(); - - // If this example has specific system requirements defined - if (example.requirements && - example.requirements[myOsVersion] && - osDetail.getDb2Level() < example.requirements[myOsVersion]) { - return false; - } - } - - return true; -} - -function sort(string1: string | SQLExample, string2: string | SQLExample) { - string1 = typeof string1 === "string" ? string1 : string1.name; - string2 = typeof string2 === "string" ? string2 : string2.name; - return string1.localeCompare(string2); +import { Event, EventEmitter, ExtensionContext, FileSystemWatcher, MarkdownString, RelativePattern, TextDocument, ThemeIcon, TreeDataProvider, TreeItem, TreeItemCollapsibleState, Uri, commands, window, workspace } from "vscode"; +import { Examples, SQLExample, ServiceInfoLabel, getMergedExamples } from "."; +import { notebookFromStatements } from "../../notebooks/logic/openAsNotebook"; +import { osDetail } from "../../config"; +import Configuration from "../../configuration"; +import * as path from 'path'; + +export const openExampleCommand = `vscode-db2i.examples.open`; + +export class ExampleBrowser implements TreeDataProvider { + private _onDidChangeTreeData: EventEmitter = new EventEmitter(); + readonly onDidChangeTreeData: Event = this._onDidChangeTreeData.event; + + private watchers: FileSystemWatcher[] = []; + private currentFilter: string | undefined; + + constructor(context: ExtensionContext) { + this.setupWatchers(); + + context.subscriptions.push( + commands.registerCommand(openExampleCommand, (example: SQLExample) => { + if (example) { + if (example.isNotebook) { + notebookFromStatements(example.content); + } else { + workspace.openTextDocument({ + content: example.content.join(`\n`), + language: `sql` + }).then(doc => { + window.showTextDocument(doc); + }); + } + } + }), + + commands.registerCommand(`vscode-db2i.examples.setFilter`, async () => { + this.currentFilter = await window.showInputBox({ + title: `Example Filter`, + prompt: `Enter filter criteria`, + value: this.currentFilter, + }); + + this.refresh(); + }), + + commands.registerCommand(`vscode-db2i.examples.clearFilter`, async () => { + this.currentFilter = undefined; + this.refresh(); + }), + + commands.registerCommand("vscode-db2i.examples.reload", () => { + delete Examples[ServiceInfoLabel]; + this.refresh(); + }), + + commands.registerCommand("vscode-db2i.examples.save", async () => { + const editor = window.activeTextEditor; + if (editor) { + const document = editor.document; + if (document.languageId === `sql`) { + const existingDirectories = Configuration.get(`examples.customExampleDirectories`) || []; + if (existingDirectories.length === 0) { + window.showErrorMessage(`You must first add a custom examples directory before saving new examples.`, { modal: true }, `Add Custom Examples Directory`).then(async selection => { + if (selection === `Add Custom Examples Directory`) { + const isAdded = await commands.executeCommand(`vscode-db2i.examples.add`); + if (isAdded) { + await commands.executeCommand(`vscode-db2i.examples.save`); + } + + return; + }; + }); + } else { + const quickPickItems = existingDirectories.map(dir => ({ label: dir })); + const selectedDirectory = await window.showQuickPick(quickPickItems, { + title: `Select a custom example directories to save this example to` + }); + if (selectedDirectory) { + const suggestedFileName = path.basename(document.fileName); + let exampleFileName = await window.showInputBox({ + title: `Example file name`, + prompt: `Enter example file name`, + value: suggestedFileName, + }); + + if (exampleFileName) { + if (!exampleFileName.includes(`.`)) { + exampleFileName = `${exampleFileName}.sql`; + } + + try { + const filePath = Uri.joinPath(Uri.file(selectedDirectory.label), exampleFileName); + const fileContent = Buffer.from(document.getText(), 'utf8') + await workspace.fs.writeFile(filePath, fileContent); + window.showInformationMessage(`Example saved to ${filePath.fsPath}`); + this.refresh(); + } catch (error) { + window.showErrorMessage(`Failed to save example: ${error}`); + } + } + } + } + } else { + window.showErrorMessage(`The active document is not a SQL file.`); + } + } else { + window.showErrorMessage(`No SQL file open.`); + } + }), + + commands.registerCommand("vscode-db2i.examples.add", async () => { + const dirsToAdd = await window.showOpenDialog({ + title: "Add Custom Example Directory", + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: true + }); + + if (dirsToAdd && dirsToAdd.length > 0) { + const existingDirectories = Configuration.get(`examples.customExampleDirectories`) || []; + const newDirectoryPaths = dirsToAdd.map(dir => dir.fsPath); + const updatedDirectories = Array.from(new Set([...existingDirectories, ...newDirectoryPaths])); + await Configuration.set(`examples.customExampleDirectories`, updatedDirectories); + return true; + } else { + return false; + } + }), + + commands.registerCommand("vscode-db2i.examples.remove", async () => { + const existingDirectories = Configuration.get(`examples.customExampleDirectories`) || []; + if (existingDirectories.length === 0) { + window.showErrorMessage(`No custom example directories to remove.`); + return; + } + + const quickPickItems = existingDirectories.map(dir => ({ label: dir })); + const selectedDirectories = await window.showQuickPick(quickPickItems, { + title: `Select a custom example directories to remove`, + canPickMany: true + }); + + if (selectedDirectories && selectedDirectories.length > 0) { + const dirsToRemove = selectedDirectories.map(item => item.label); + const updatedDirectories = existingDirectories.filter(dir => !dirsToRemove.includes(dir)); + await Configuration.set(`examples.customExampleDirectories`, updatedDirectories); + } + }), + + commands.registerCommand("vscode-db2i.examples.edit", async (item: SQLExampleItem) => { + if (item.example.customFileUri) { + const document = await workspace.openTextDocument(item.example.customFileUri); + await window.showTextDocument(document); + } + }), + + workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('vscode-db2i.examples.customExampleDirectories')) { + this.setupWatchers(); + this.refresh(); + } + }) + ); + } + + refresh() { + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: any): TreeItem | Thenable { + return element; + } + + async getChildren(element?: ExampleGroupItem): Promise { + if (element) { + return element.getChildren(); + } + else { + const mergedExamples = await getMergedExamples(); + + if (this.currentFilter) { + // If there is a filter, then show all examples that include this criteria + const upperFilter = this.currentFilter.toUpperCase(); + return Object.values(mergedExamples) + .flatMap(examples => examples.filter(exampleWorksForOnOS)) + .filter(example => example.name.toUpperCase().includes(upperFilter) || example.content.some(line => line.toUpperCase().includes(upperFilter))) + .sort(sort) + .map(example => new SQLExampleItem(example)); + } + else { + return Object.entries(mergedExamples) + .sort(([name1], [name2]) => sort(name1, name2)) + .map(([name, examples]) => new ExampleGroupItem(name, examples)); + } + } + } + + setupWatchers() { + if (this.watchers) { + for (const watcher of this.watchers) { + watcher.dispose(); + } + this.watchers = []; + } + + const existingDirectories = Configuration.get(`examples.customExampleDirectories`) || []; + for (const directory of existingDirectories) { + const directoryUri = Uri.file(directory); + const relativePattern = new RelativePattern(directoryUri, '**/*'); + const watcher = workspace.createFileSystemWatcher(relativePattern); + watcher.onDidCreate(async (uri) => { + this.refresh(); + }); + watcher.onDidChange(async (uri) => { + this.refresh(); + }); + watcher.onDidDelete(async (uri) => { + this.refresh(); + }); + this.watchers.push(watcher); + } + } +} + +class ExampleGroupItem extends TreeItem { + constructor(name: string, private group: SQLExample[]) { + super(name, TreeItemCollapsibleState.Collapsed); + this.iconPath = ThemeIcon.Folder; + } + + getChildren(): SQLExampleItem[] { + return this.group + .filter(example => exampleWorksForOnOS(example)) + .sort(sort) + .map(example => new SQLExampleItem(example)); + } +} + +class SQLExampleItem extends TreeItem { + constructor(public example: SQLExample) { + super(example.name, TreeItemCollapsibleState.None); + this.iconPath = ThemeIcon.File; + this.resourceUri = Uri.parse('_.sql'); + this.tooltip = new MarkdownString(['```sql', example.content.join(`\n`), '```'].join(`\n`)); + this.contextValue = example.customFileUri ? `example.custom` : `example`; + + this.command = { + command: openExampleCommand, + title: `Open example`, + arguments: [example] + }; + } +} + +function exampleWorksForOnOS(example: SQLExample): boolean { + + if (osDetail) { + const myOsVersion = osDetail.getVersion(); + + // If this example has specific system requirements defined + if (example.requirements && + example.requirements[myOsVersion] && + osDetail.getDb2Level() < example.requirements[myOsVersion]) { + return false; + } + } + + return true; +} + +function sort(string1: string | SQLExample, string2: string | SQLExample) { + string1 = typeof string1 === "string" ? string1 : string1.name; + string2 = typeof string2 === "string" ? string2 : string2.name; + return string1.localeCompare(string2); } \ No newline at end of file diff --git a/src/views/examples/index.ts b/src/views/examples/index.ts index 997e61a7..aa725cfb 100644 --- a/src/views/examples/index.ts +++ b/src/views/examples/index.ts @@ -1,3 +1,9 @@ +import { EndOfLine, FileType, TextDocument, Uri, workspace } from "vscode"; +import Configuration from "../../configuration"; +import * as path from "path"; +import { getServiceInfo } from "../../database/serviceInfo"; +import { getStatementDetail } from "../../notebooks/logic/statement"; + export interface SQLExamplesList { [group: string]: SQLExample[] } @@ -12,6 +18,7 @@ export interface SQLExample { content: string[]; requirements?: ExampleSystemRequirements; isNotebook?: boolean; + customFileUri?: Uri; }; // Unlike the bulk of the examples defined below, the services examples are retrieved dynamically @@ -5978,4 +5985,115 @@ export const Examples: SQLExamplesList = { ] } ] +} + +export async function getMergedExamples(): Promise { + const defaultExamples = await getDefaultExamples(); + const customExamples = await getCustomExamples(); + const mergedExamples: SQLExamplesList = {}; + + for (const [group, examples] of Object.entries(defaultExamples)) { + mergedExamples[group] = [...examples]; + } + + for (const [group, examples] of Object.entries(customExamples)) { + if (mergedExamples[group]) { + mergedExamples[group] = [...mergedExamples[group], ...examples]; + } else { + mergedExamples[group] = [...examples]; + } + } + + return mergedExamples; +} + +async function getDefaultExamples(): Promise { + // Unlike the bulk of the examples which are defined in views/examples/index.ts, the services examples are retrieved dynamically + if (!Examples[ServiceInfoLabel]) { + Examples[ServiceInfoLabel] = await getServiceInfo(); + } + + return Examples; +} + +export async function getCustomExamples(): Promise { + // Get custom example directories from VS Code settings + const customDirectoryPaths = Configuration.get(`examples.customExampleDirectories`) || []; + if (customDirectoryPaths.length === 0) { + return {}; + } + + // Get all SQL files from the specified directories (include 1 level of subdirectories) + const sqlTextDocuments: TextDocument[] = []; + for (const dirPath of customDirectoryPaths) { + const dirUri = Uri.file(dirPath); + const dirTextDocuments = await getSqlTextDocumentsFromDirectory(dirUri); + sqlTextDocuments.push(...dirTextDocuments); + } + + // Organize the SQL files into groups based on their parent directory names + const examplesList: SQLExamplesList = {}; + for (const textDocument of sqlTextDocuments) { + // Set group and name based on file path by default + const parsedPath = path.parse(textDocument.uri.path); + let group = path.basename(parsedPath.dir); + let name = parsedPath.name; + + // Override group and name if category and description is specified in the SQL file as comments + const content = textDocument.getText(); + const eol = textDocument.eol === EndOfLine.LF ? '\n' : '\r\n'; + const detail = getStatementDetail(content, eol); + const category = detail?.settings.category; + if (category) { + group = category; + } + const description = detail?.settings.description; + if (description) { + name = description; + } + + if (!examplesList[group]) { + examplesList[group] = []; + } + + examplesList[group].push({ + name: name, + content: textDocument.getText().split(eol), + customFileUri: textDocument.uri + }); + } + + return examplesList; +} + +async function getSqlTextDocumentsFromDirectory(directory: Uri, depth = 1): Promise { + const sqlTextDocuments: TextDocument[] = []; + + try { + const contents = await workspace.fs.readDirectory(directory); + for (const [name, type] of contents) { + const uri = Uri.joinPath(directory, name); + + if (type === FileType.File) { + try { + const textDocument = await workspace.openTextDocument(uri); + if (textDocument.languageId === 'sql') { + sqlTextDocuments.push(textDocument); + } + } catch (error) { + // Ignore error reading file + console.log(error); + continue; + } + } else if (type === FileType.Directory && depth > 0) { + const subContents = await getSqlTextDocumentsFromDirectory(uri, depth - 1); + sqlTextDocuments.push(...subContents); + } + } + } catch (error) { + // Ignore error reading directory + console.log(error); + } + + return sqlTextDocuments; } \ No newline at end of file diff --git a/src/views/queryHistoryView/contributes.json b/src/views/queryHistoryView/contributes.json index 1a37c109..781d9c72 100644 --- a/src/views/queryHistoryView/contributes.json +++ b/src/views/queryHistoryView/contributes.json @@ -59,13 +59,13 @@ ], "view/title": [ { - "command": "vscode-db2i.queryHistory.clear", - "group": "navigation", + "command": "vscode-db2i.queryHistory.find", + "group": "navigation@0", "when": "view == queryHistory" }, { - "command": "vscode-db2i.queryHistory.find", - "group": "navigation", + "command": "vscode-db2i.queryHistory.clear", + "group": "navigation@1", "when": "view == queryHistory" } ],