diff --git a/TODO.md b/TODO.md index e34a882..bb63c34 100644 --- a/TODO.md +++ b/TODO.md @@ -31,7 +31,7 @@ Create and manage tab groups to improve your development workflow. ### Extension Tests - [ ] Create tests for the extension - * [x] Test tree_item + * [x] Test TreeItem * [ ] Test tree_view ### Misc diff --git a/src/TreeDataProvider.ts b/src/TreeDataProvider.ts new file mode 100644 index 0000000..058f84e --- /dev/null +++ b/src/TreeDataProvider.ts @@ -0,0 +1,549 @@ +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { TreeItem } from "./TreeItem"; +import { EXTENSION_ID } from './constants'; + +/** + * The tree view class. Represents the explorer tree. + */ +export class TreeDataProvider implements vscode.TreeDataProvider { + private readonly context: vscode.ExtensionContext; + // m_data holds all tree items + private m_data: TreeItem[] = []; + private treeView: vscode.TreeView | undefined; + // with the vscode.EventEmitter we can refresh our tree view + private m_onDidChangeTreeData: vscode.EventEmitter = + new vscode.EventEmitter(); + // and vscode will access the event by using a readonly onDidChangeTreeData + //(this member has to be named like here, otherwise vscode doesnt update our treeview. + readonly onDidChangeTreeData?: vscode.Event = this.m_onDidChangeTreeData.event; + + /** + * Create a new tree view. + * Register all the commands available for this tree. + * @param context The context of the extension + */ + constructor(context: vscode.ExtensionContext) { + this.context = context; + + // Top level, add a new group + vscode.commands.registerCommand("vs_tab_groups.addTabGroup", () => this.addTabGroup()); + vscode.commands.registerCommand("vs_tab_groups.removeAllGroups", () => this.removeAllGroups()); + + // Mid level, actions on tab groups + vscode.commands.registerCommand("vs_tab_groups.addEntry", (item) => this.addEntry(item)); + vscode.commands.registerCommand("vs_tab_groups.openTabGroup", (item) => this.openTabGroup(item)); + vscode.commands.registerCommand("vs_tab_groups.closeTabGroup", (item) => this.closeTabGroup(item)); + vscode.commands.registerCommand("vs_tab_groups.editTabGroupIcon", (item) => this.editTabGroupIcon(item)); + vscode.commands.registerCommand("vs_tab_groups.removeTabGroup", (item) => this.removeTabGroup(item)); + + // Low level, actions on tabs + vscode.commands.registerCommand("vs_tab_groups.openTab", (item) => this.openTab(item)); + vscode.commands.registerCommand("vs_tab_groups.closeTab", (item) => this.closeTab(item)); + vscode.commands.registerCommand("vs_tab_groups.removeTab", (item) => this.removeTab(item)); + + // General + vscode.commands.registerCommand("vs_tab_groups.item_clicked", (item) => this.item_clicked(item)); + } + + public setTreeView(treeView: vscode.TreeView) { + this.treeView = treeView; + } + + /** + * Save this tree in the workspace. + */ + public async save() { + var treeData: any = {}; + + // loop the data in this tree and pass it all to json format + for (let i = 0; i < this.m_data.length; i++) { + const itemJSON = await this.m_data[i].toJSON(); + treeData[`key_${i}`] = itemJSON; + } + + this.context.workspaceState.update("treeData", treeData); + } + + /** + * Load a tree from the workspace. + */ + public async load() { + const treeData: any = this.context.workspaceState.get("treeData"); + + for (let key in treeData) { + let objValue = treeData[key]; + const item = await TreeItem.fromJSON(objValue); + this.m_data.push(item); + } + + this.m_onDidChangeTreeData.fire(undefined); + } + + /** + * @inheritDoc + */ + public getTreeItem(item: TreeItem): vscode.TreeItem | Thenable { + let title = item.label ? item.label.toString() : ""; + let result = new vscode.TreeItem(title, item.collapsibleState); + // here we add our command which executes our memberfunction + result.command = { + command: "vs_tab_groups.item_clicked", + title: title, + arguments: [item], + }; + result.contextValue = item.isRoot ? "vstg_root_item" : "vstg_child_item"; + result.iconPath = item.iconPath; + return result; + } + + /** + * @inheritDoc + */ + public getChildren(element: TreeItem | undefined): vscode.ProviderResult { + if (element === undefined) { + return this.m_data; + } + return element.children; + } + + /** + * @inheritdoc + */ + public getParent(element: TreeItem): vscode.ProviderResult { + if (this.m_data.includes(element)) { + return undefined; + } + return undefined; + } + + /*** TOP LEVEL ***/ + + /** + * Check if a label already exists for a parent of this tree + * @param label The label to check for + * @returns True if an item already uses this label, false if otherwise. + */ + labelExists(label: string): boolean { + for (let item of this.m_data) { + if (item.label === label) { + return true; + } + } + return false; + } + + /** + * Create a new group of tabs. + */ + async addTabGroup() { + const input = await vscode.window.showInputBox({ + prompt: "Type in the name of the tab group to be created.\n", + }); + + if (input && input !== "") { + if (this.labelExists(input)) { + vscode.window.showErrorMessage(`Can not have two tab groups with name '${input}'`); + return; + } + this.m_data.push(new TreeItem(input, null, true)); + this.m_onDidChangeTreeData.fire(undefined); + } + + this.save(); + } + + /** + * Remove all groups of tabs. Fully clears the tree. + */ + removeAllGroups() { + vscode.window.showInformationMessage("Are you sure you want to remove all groups?", "Yes", "No") + .then((answer) => { + if (answer === "Yes") { + this.m_data = []; + this.m_onDidChangeTreeData.fire(undefined); + + this.save(); + } + }); + } + + /*** MID LEVEL ***/ + + /** + * Escape a string path pattern + * @param s A string representing a path + * @returns The path string in a format that is usable in a regex + */ + escapeRegExp(s: string) { + s = s.replace(/\\/g, "\\\\"); // escape all backslashes + s = s.replace(/\./g, "\\."); // escape all dots + s = s.replace(/\//g, "\\/"); // escape all forward slashes + s = s.replace(/\*/g, ".*"); // when used *, the user means anything, i.e., .* + return s; + } + + /** + * Check if a path should be ignored + * @param rootPath The path pointing to the root + * @param pathsToIgnore The patterns to ignore + * @param currentPath The path to be checked + * @returns True if currentPath should be ignored. False if otherwise. + */ + shouldIgnorePath(rootPath: string, pathsToIgnore: string[], currentPath: string) { + for (let pattern of pathsToIgnore) { + var complete_path = path.join(rootPath, pattern); + complete_path = this.escapeRegExp(complete_path); + + const regex = new RegExp(complete_path, "g"); + if (regex.test(currentPath)) { + return true; + } + } + return false; + } + + /** + * Get all files recursively from a directory + * @param workspaceDir The workspace directory. Used to remove common paths from the strings. + * @param pathsToIgnore The patterns to ignore + * @param dir The directory to search. + * @returns An array containing all files in the directory. + */ + traverseDir(workspaceDir: string, pathsToIgnore: string[], dir: string) { + var files: string[] = []; + fs.readdirSync(dir).forEach(async (file) => { + let fullPath = path.join(dir, file); + + // Check if path should be ignored + if (this.shouldIgnorePath(workspaceDir, pathsToIgnore, fullPath)) { + return; + } + + if (fs.lstatSync(fullPath).isDirectory()) { + files = files.concat(this.traverseDir(workspaceDir, pathsToIgnore, fullPath)); + } else { + files.push(fullPath.replace(workspaceDir + path.sep, "")); + } + }); + return files; + } + + /** + * Add an entry to a parent item. + * @param item The parent item. + */ + async addEntry(item: TreeItem) { + if (!vscode.window.activeTextEditor) { + vscode.window.showErrorMessage(`Could not find an open editor. Try to open a file first.`); + return; + } + const currentWorkSpace = await vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri); + if (!currentWorkSpace) { + vscode.window.showErrorMessage(`No workspace has been found.`); + return; + } + const workspaceDir = currentWorkSpace.uri.fsPath; + + var quickPickItems = []; + + // Get labels of all open tabs + vscode.window.tabGroups.all.forEach((group) => + group.tabs.forEach((tab) => { + var label = tab.label; + if (tab.input instanceof vscode.TabInputText) { + label = tab.input.uri.fsPath; + label = label.replace(workspaceDir + path.sep, ""); + } + quickPickItems.push({ label: label }); + }) + ); + + if (quickPickItems.length > 0) { + // make a separator for the 'Open Tabs' group + const tabsSeparator = { + label: "Open Tabs", + kind: vscode.QuickPickItemKind.Separator, // this is new + }; + + // put the 'Open Tabs' separator at the beginning + quickPickItems.unshift(tabsSeparator); + } + + const ignorePaths: string[] | undefined = vscode.workspace.getConfiguration("vs-tab-groups").get("ignorePaths"); + const allFiles = this.traverseDir(workspaceDir, ignorePaths ? ignorePaths : [], workspaceDir); + + if (allFiles.length > 0) { + // make a separator for the 'File' group + const fileSeparator = { + label: "All Files", + kind: vscode.QuickPickItemKind.Separator, + }; + + quickPickItems.push(fileSeparator); + + allFiles.forEach((fName) => quickPickItems.push({ label: fName })); + } + + // Display the selection box + const filesSelections = await vscode.window.showQuickPick(quickPickItems, { + canPickMany: true, + placeHolder: "Select files", + }); + + // Add to tree if something was selected + if (filesSelections) { + for (let selectionObj of filesSelections) { + const label = selectionObj["label"]; + const file_path = workspaceDir + path.sep + label; + + const newChild = new TreeItem(label, file_path, false); + if (item.label) { + newChild.setParentLabel(item.label?.toString()); + } + + item?.add_child(newChild); + } + this.m_onDidChangeTreeData.fire(undefined); + + // Save the tree to the context + this.save(); + } + } + + /** + * Open the tab group, i.e., all files in the group, in the editor. + * @param item The item that represents the root of the group. + */ + async openTabGroup(item: TreeItem) { + // Check if the user wants the other tabs to be closed when opening a new group + if (vscode.workspace.getConfiguration("vs-tab-groups").get("closeTabsOnOpenGroup")) { + // close every open tab + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + } + + // Open the editor for every child of this tree + for (let child of item.children) { + this.openEditor(child.file); + } + } + + /** + * Close the tab group, i.e., all files in the group, in the editor. + * @param item The item that represents the root of the group. + */ + closeTabGroup(item: TreeItem) { + var file_paths: string[] = []; + for (let child of item.children) { + if (child.file) { + file_paths.push(child.file); + } + } + this.closeEditor(file_paths); + } + + /** + * Change the icon of the group's root. + * @param item The item representing the root of the group. + */ + async editTabGroupIcon(item: TreeItem) { + const defaultEmojis = ["🟥", "🟧", "🟨", "🟩", "🟦", "🟪", "🟫", "⬛", "⬜"]; + const icons = ["red", "orange", "yellow", "green", "blue", "purple", "brown", "black", "white"]; + + const iconSelection = await vscode.window.showQuickPick(defaultEmojis, { + canPickMany: false, + placeHolder: "Select icon", + }); + + if (iconSelection) { + var index: number = defaultEmojis.indexOf(iconSelection, 0); + + const p: string = path.join(__filename, "..", "..", "resources", `${icons[index]}_square.png`); + item.iconPath = { light: p, dark: p }; + + this.m_onDidChangeTreeData.fire(undefined); + } + } + + /** + * Removes the tab group from the tree + * @param item The item representing the root of the group. + */ + removeTabGroup(item: TreeItem) { + var index: number = this.m_data.indexOf(item, 0); + if (index > -1) { + this.m_data.splice(index, 1); + + this.m_onDidChangeTreeData.fire(undefined); + + // Save the tree to the context + this.save(); + } + } + + /*** LOW LEVEL ***/ + + /** + * Open a tab in the editor. + * @param item The item representing the file to be opened. + */ + openTab(item: TreeItem) { + this.openEditor(item.file); + } + + /** + * Close a tab in the editor. + * @param item The item representing the file to be closed. + */ + closeTab(item: TreeItem) { + if (item.file) { + this.closeEditor([item.file]); + } + } + + /** + * Remove a tab/file from the group. + * @param item The item representing the file to be removed from the group. + */ + removeTab(item: TreeItem) { + // Loop through every group in the tree + for (let tab_group of this.m_data) { + // If the group is not the parent of the item, continue + if (tab_group.label !== item.parentLabel) { + continue; + } + // Find the index of the item in the group's children + var index: number = tab_group.children.indexOf(item, 0); + if (index > -1) { + tab_group.children.splice(index, 1); + + this.m_onDidChangeTreeData.fire(undefined); + + // Save the tree to the context + this.save(); + + break; + } + } + } + + /*** GENERAL ***/ + + /** + * Gets an open document for a file or opens a new one and returns it + * @param filePaths The paths of the files to open + * @returns An array of TextDocument with the documents for each of the file paths provided + */ + async getOpenDocuments(filePaths: string[]) { + var docs: vscode.TextDocument[] = []; + + filePaths.forEach(async (fpath) => { + var found = false; + vscode.workspace.textDocuments.forEach((doc) => { + if (doc.fileName === fpath) { + docs.push(doc); + found = true; + return; + } + }); + + if (!found) { + docs.push(await vscode.workspace.openTextDocument(fpath)); + } + }); + return docs; + } + + /** + * Open a file in the editor + * @param filePath The path of the file to be opened. + */ + async openEditor(filePath: string | null) { + if (filePath === null || filePath === undefined) { + return; + } + + this.getOpenDocuments([filePath]) + .then((documents) => { + if (documents.length > 0) { + vscode.window + .showTextDocument(documents[0], { preview: false }) + .then() + .then(undefined, (err) => { + // This is most likely not a problem that needs solving. + // An error is thrown when multiple files are being opened at the same time + //console.error('An error has occurred while trying to show file :: ', err); + //vscode.window.showErrorMessage(`Failed to show document '${filePath}'.`); + }); + } + }) + .then(undefined, (err) => { + //console.error('An error has occurred while trying to open file :: ', err); + vscode.window.showErrorMessage(`Failed to open document '${filePath}'.`); + }); + } + + /** + * Gets the documents that have tabs open in the editor + * @param filePaths An array with the paths of files to check for + * @returns An array of TextDocument that are open in tabs + */ + getOpenDocmentsInWorkSpace(filePaths: string[] | null) { + if (!filePaths) { + return undefined; + } + + var editorTabs: string[] = []; + + // Find all open tabs + vscode.window.tabGroups.all.forEach((group) => + group.tabs.forEach((tab) => { + if (!(tab.input instanceof vscode.TabInputText)) { + return; + } + + // Check if the tab is in the paths + if (filePaths.indexOf(tab.input.uri.fsPath) > -1) { + editorTabs.push(tab.input.uri.fsPath); + } + }) + ); + + // None of the files is open in a tab + if (editorTabs.length === 0) { + return undefined; + } + + return this.getOpenDocuments(editorTabs); + } + + /** + * Close files in the editor + * @param filePaths An array with the paths of the files to be closed. + */ + async closeEditor(filePaths: string[] | null) { + var documents = await this.getOpenDocmentsInWorkSpace(filePaths); + if (!documents || documents.length === 0) { + return; + } + + for (let doc of documents) { + await vscode.window.showTextDocument(doc, { + preview: true, + preserveFocus: false, + }); + await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + } + } + + /** + * Open the clicked tab/file in the editor. + * @param item The item representing the tab/file that was clicked. + */ + item_clicked(item: TreeItem) { + if (!item.isRoot) { + this.openEditor(item.file); + } + } +} diff --git a/src/TreeItem.ts b/src/TreeItem.ts new file mode 100644 index 0000000..de776e6 --- /dev/null +++ b/src/TreeItem.ts @@ -0,0 +1,159 @@ +import * as vscode from 'vscode' +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * The tree item class. Represents an item in the explorer tree. + */ +export class TreeItem extends vscode.TreeItem +{ + readonly isRoot: boolean; + readonly file: string | null; + + public children: TreeItem[] = []; + public parentLabel: string | undefined = undefined; + + /** + * Create a new item + * @param label The label to be displayed + * @param file The file this item references + * @param isRoot A boolean identifying the item as a root or a child + */ + constructor(label: string, file: string | null, isRoot: boolean) + { + super(label, vscode.TreeItemCollapsibleState.None); + this.isRoot = isRoot; + this.file = file; + this.collapsibleState = isRoot ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; + this.iconPath = isRoot ? undefined : vscode.ThemeIcon.File + } + + /** + * Set the parent's label. + * @param pLabel The parent's label + */ + public setParentLabel(pLabel: string) + { + if (this.isRoot) { + throw new Error('Cannot assign parentLabel to a root object.'); + } + this.parentLabel = pLabel; + } + + /** + * Get a child's index from this tree that matches a given item + * @param other The item to use as comparison + * @returns The child that matches the given item, or undefined. + */ + public get_child_index(other : TreeItem) + { + let i = 0; + for(let item of this.children) { + if (item.label === other.label && item.file === other.file) { + return i; + } + i += 1; + } + return -1; + } + + /** + * Check if this item has a child that matches a given item + * @param other The item to be checked for + * @returns True if this item has a child that matches the given object, or false if otherwise + */ + public has_child(other : TreeItem) + { + return this.get_child_index(other) > -1 + } + + /** + * Add a child to this item + * @param other The item to be added + */ + public add_child (other : TreeItem) + { + // Only add if this object is a root + if (!this.isRoot) { + throw new Error('Can not add child to child item.'); + } + + this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + + // if there is already this child, ignore + const index = this.get_child_index(other) + if (index > -1) { + vscode.window.showWarningMessage(`File with path '${other.file}' has already been added to this group.`); + } + else { + this.children.push(other); + } + } + + /** + * Remove a child from this item + * @param other The item to be removed + * @returns True if the child was removed. False otherwise + */ + public remove_child (other : TreeItem) + { + + // TODO + + return false + } + + /** + * Convert this item into JSON data + * @returns This item's data as a JSON + */ + public async toJSON() + { + var childrenData: any = {} + + // loop the data in this tree and pass it all to json format + for (let i = 0; i < this.children.length; i++) { + const childJSON = await this.children[i].toJSON(); + childrenData[`key_${i}`] = childJSON; + } + + return { + label: this.label, + file: this.file, + isRoot: this.isRoot, + iconPath: this.iconPath, + children: childrenData, + parentLabel: this.parentLabel + } + } + + /** + * Convert JSON formatted data into a tree item object + * @param data The data to be parsed + * @returns A new TreeItem object + */ + public static async fromJSON(data: any) + { + const label = data["label"]; + const file_path = data["file"]; + const isRoot = data["isRoot"]; + + const item = new TreeItem(label, file_path, isRoot); + + if (isRoot && "iconPath" in data){ + item.iconPath = data["iconPath"] + } + + if (!isRoot && "parentLabel" in data){ + item.setParentLabel( data["parentLabel"] ) + } + + for (let key in data["children"]) { + let objValue = data["children"][key]; + const child = await TreeItem.fromJSON(objValue); + item.add_child( child ) + } + + return item + } +} \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..a2d1619 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +export const EXTENSION_ID = "vs_tab_groups"; \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index da6098e..de89044 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,15 +1,19 @@ // VS Code extensibility API import * as vscode from 'vscode'; -import { vstg } from './tree_view'; +import { TreeDataProvider } from './TreeDataProvider'; +import { EXTENSION_ID } from './constants'; /** * Called when the extension is started */ export function activate(context: vscode.ExtensionContext) { - let tree = new vstg.tree_view(context); - vscode.window.registerTreeDataProvider('vs_tab_groups', tree); - tree.load(); + let treeDataProvider = new TreeDataProvider(context); + const treeView = vscode.window.createTreeView(EXTENSION_ID, { + treeDataProvider: treeDataProvider + }); + treeDataProvider.setTreeView(treeView); + treeDataProvider.load(); return context; } diff --git a/src/test/suite/tree_view.test.ts b/src/test/suite/TreeDataProviderTest.test.ts similarity index 63% rename from src/test/suite/tree_view.test.ts rename to src/test/suite/TreeDataProviderTest.test.ts index 0ffbd8e..76fa11a 100644 --- a/src/test/suite/tree_view.test.ts +++ b/src/test/suite/TreeDataProviderTest.test.ts @@ -2,10 +2,10 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; -import { vstg } from '../../tree_view'; +import { TreeDataProvider } from '../../TreeDataProvider'; -suite('TreeView Test Suite', () => { - vscode.window.showInformationMessage('Start all \'TreeView\' tests.'); +suite('TreeDataProvider Test Suite', () => { + vscode.window.showInformationMessage('Start all \'TreeDataProvider\' tests.'); test("Should start extension 'VS Tab Groups'", async () => { const started = vscode.extensions.getExtension( @@ -15,14 +15,14 @@ suite('TreeView Test Suite', () => { assert.equal(started?.isActive, true); }); - test("Create new tree view", async () => { + test("Create new tree data provider", async () => { const ext = vscode.extensions.getExtension("bentodaniel.vs-tab-groups"); if (!ext) { throw Error("Could not get extension.") } const extensionContext = await ext.activate(); - const tree_view = new vstg.tree_view(extensionContext); + const treeDataProvider = new TreeDataProvider(extensionContext); //assert.equal() }) diff --git a/src/test/suite/tree_item.test.ts b/src/test/suite/TreeItemTest.test.ts similarity index 80% rename from src/test/suite/tree_item.test.ts rename to src/test/suite/TreeItemTest.test.ts index ecd01c6..cd7b21a 100644 --- a/src/test/suite/tree_item.test.ts +++ b/src/test/suite/TreeItemTest.test.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; -import { vstg } from '../../tree_view'; +import { TreeItem } from '../../TreeItem'; suite('TreeItem Test Suite', () => { vscode.window.showInformationMessage('Start all \'TreeItem\' tests.'); @@ -16,7 +16,7 @@ suite('TreeItem Test Suite', () => { }); test("Create tree root item", async () => { - const root_item = new vstg.tree_item("Root", null, true); + const root_item = new TreeItem("Root", null, true); assert.equal(root_item.label, "Root"); assert.equal(root_item.isRoot, true); assert.equal(root_item.file, null); @@ -25,7 +25,7 @@ suite('TreeItem Test Suite', () => { }) test("Create tree child item", async () => { - const child_item = new vstg.tree_item("Child", "path/to/file", false); + const child_item = new TreeItem("Child", "path/to/file", false); assert.equal(child_item.label, "Child"); assert.equal(child_item.isRoot, false); assert.equal(child_item.file, "path/to/file"); @@ -34,7 +34,7 @@ suite('TreeItem Test Suite', () => { }) test("Try set parent label on root", async () => { - const item = new vstg.tree_item("Root", null, true); + const item = new TreeItem("Root", null, true); assert.equal(item.parentLabel, undefined); assert.throws( () => { item.setParentLabel("Root's parent") }, @@ -44,15 +44,15 @@ suite('TreeItem Test Suite', () => { }) test("Set parent label on child", async () => { - const child_item = new vstg.tree_item("Child", "path/to/file", false); + const child_item = new TreeItem("Child", "path/to/file", false); assert.equal(child_item.parentLabel, undefined); child_item.setParentLabel("Root"); assert.equal(child_item.parentLabel, "Root"); }) test("Add child to root", async () => { - const root_item = new vstg.tree_item("Root", null, true); - const child_item = new vstg.tree_item("Child", "path/to/file", false); + const root_item = new TreeItem("Root", null, true); + const child_item = new TreeItem("Child", "path/to/file", false); root_item.add_child(child_item); @@ -61,8 +61,8 @@ suite('TreeItem Test Suite', () => { }) test("Try add child to child", async () => { - const child_item = new vstg.tree_item("Child", "path/to/file", false); - const child_child_item = new vstg.tree_item("Child Child", "path/to/file/child", false); + const child_item = new TreeItem("Child", "path/to/file", false); + const child_child_item = new TreeItem("Child Child", "path/to/file/child", false); assert.equal(child_item.isRoot, false); assert.throws( () => { child_item.add_child(child_child_item) }, @@ -72,8 +72,8 @@ suite('TreeItem Test Suite', () => { }) test("Remove child that exists", async () => { - const root_item = new vstg.tree_item("Root", null, true); - const child_item = new vstg.tree_item("Child", "path/to/file", false); + const root_item = new TreeItem("Root", null, true); + const child_item = new TreeItem("Child", "path/to/file", false); root_item.add_child(child_item); @@ -87,15 +87,15 @@ suite('TreeItem Test Suite', () => { }) test("Remove child that does not exist", async () => { - const root_item = new vstg.tree_item("Root", null, true); - const child_item = new vstg.tree_item("Child", "path/to/file", false); + const root_item = new TreeItem("Root", null, true); + const child_item = new TreeItem("Child", "path/to/file", false); root_item.add_child(child_item); assert.equal(root_item.children.length, 1); assert.equal(root_item.children[0], child_item); - const other_item = new vstg.tree_item("Other", "path/to/other/file", false); + const other_item = new TreeItem("Other", "path/to/other/file", false); const result = root_item.remove_child(other_item); assert.equal(result, false); @@ -103,7 +103,7 @@ suite('TreeItem Test Suite', () => { }) test("Convert item without children to JSON", async () => { - const root_item = new vstg.tree_item("Root", null, true); + const root_item = new TreeItem("Root", null, true); const dataJSON = await root_item.toJSON(); assert.equal(Object.keys(dataJSON).length, 6); @@ -116,8 +116,8 @@ suite('TreeItem Test Suite', () => { }) test("Convert item with children to JSON", async () => { - const root_item = new vstg.tree_item("Root", null, true); - const child_item = new vstg.tree_item("Child", "path/to/file", false); + const root_item = new TreeItem("Root", null, true); + const child_item = new TreeItem("Child", "path/to/file", false); root_item.add_child(child_item); child_item.setParentLabel("Root") @@ -152,7 +152,7 @@ suite('TreeItem Test Suite', () => { children: {}, //parentLabel: undefined } - const item = await vstg.tree_item.fromJSON(dataJSON); + const item = await TreeItem.fromJSON(dataJSON); assert.equal(item.label, "Root"); assert.equal(item.file, null); @@ -180,7 +180,7 @@ suite('TreeItem Test Suite', () => { }, //parentLabel: undefined } - const item = await vstg.tree_item.fromJSON(dataJSON); + const item = await TreeItem.fromJSON(dataJSON); // Asserts on root assert.equal(item.label, "Root"); diff --git a/src/tree_view.ts b/src/tree_view.ts deleted file mode 100644 index cadb812..0000000 --- a/src/tree_view.ts +++ /dev/null @@ -1,706 +0,0 @@ -import * as vscode from 'vscode' -import * as fs from 'fs'; -import * as path from 'path'; - -export namespace vstg -{ - /** - * The tree item class. Represents an item in the explorer tree. - */ - export class tree_item extends vscode.TreeItem - { - readonly isRoot: boolean; - readonly file: string | null; - - public children: tree_item[] = []; - public parentLabel: string | undefined = undefined; - - /** - * Create a new item - * @param label The label to be displayed - * @param file The file this item references - * @param isRoot A boolean identifying the item as a root or a child - */ - constructor(label: string, file: string | null, isRoot: boolean) - { - super(label, vscode.TreeItemCollapsibleState.None); - this.isRoot = isRoot; - this.file = file; - this.collapsibleState = isRoot ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; - this.iconPath = isRoot ? undefined : vscode.ThemeIcon.File - } - - /** - * Set the parent's label. - * @param pLabel The parent's label - */ - public setParentLabel(pLabel: string) - { - if (this.isRoot) { - throw new Error('Cannot assign parentLabel to a root object.'); - } - this.parentLabel = pLabel; - } - - /** - * Get a child's index from this tree that matches a given item - * @param other The item to use as comparison - * @returns The child that matches the given item, or undefined. - */ - public get_child_index(other : tree_item) - { - let i = 0; - for(let item of this.children) { - if (item.label === other.label && item.file === other.file) { - return i; - } - i += 1; - } - return -1; - } - - /** - * Check if this item has a child that matches a given item - * @param other The item to be checked for - * @returns True if this item has a child that matches the given object, or false if otherwise - */ - public has_child(other : tree_item) - { - return this.get_child_index(other) > -1 - } - - /** - * Add a child to this item - * @param other The item to be added - */ - public add_child (other : tree_item) - { - // Only add if this object is a root - if (!this.isRoot) { - throw new Error('Can not add child to child item.'); - } - - this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; - - // if there is already this child, ignore - const index = this.get_child_index(other) - if (index > -1) { - vscode.window.showWarningMessage(`File with path '${other.file}' has already been added to this group.`); - } - else { - this.children.push(other); - } - } - - /** - * Remove a child from this item - * @param other The item to be removed - * @returns True if the child was removed. False otherwise - */ - public remove_child (other : tree_item) - { - - // TODO - - return false - } - - /** - * Convert this item into JSON data - * @returns This item's data as a JSON - */ - public async toJSON() - { - var childrenData: any = {} - - // loop the data in this tree and pass it all to json format - for (let i = 0; i < this.children.length; i++) { - const childJSON = await this.children[i].toJSON(); - childrenData[`key_${i}`] = childJSON; - } - - return { - label: this.label, - file: this.file, - isRoot: this.isRoot, - iconPath: this.iconPath, - children: childrenData, - parentLabel: this.parentLabel - } - } - - /** - * Convert JSON formatted data into a tree item object - * @param data The data to be parsed - * @returns A new tree_item object - */ - public static async fromJSON(data: any) - { - const label = data["label"]; - const file_path = data["file"]; - const isRoot = data["isRoot"]; - - const item = new tree_item(label, file_path, isRoot); - - if (isRoot && "iconPath" in data){ - item.iconPath = data["iconPath"] - } - - if (!isRoot && "parentLabel" in data){ - item.setParentLabel( data["parentLabel"] ) - } - - for (let key in data["children"]) { - let objValue = data["children"][key]; - const child = await tree_item.fromJSON(objValue); - item.add_child( child ) - } - - return item - } - } - - /**************************************************** - ****************************************************/ - - /** - * The tree view class. Represents the explorer tree. - */ - export class tree_view implements vscode.TreeDataProvider - { - private readonly context: vscode.ExtensionContext; - // m_data holds all tree items - private m_data : tree_item [] = []; - // with the vscode.EventEmitter we can refresh our tree view - private m_onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); - // and vscode will access the event by using a readonly onDidChangeTreeData (this member has to be named like here, otherwise vscode doesnt update our treeview. - readonly onDidChangeTreeData ? : vscode.Event = this.m_onDidChangeTreeData.event; - - /** - * Create a new tree view. - * Register all the commands available for this tree. - * @param context The context of the extension - */ - constructor(context: vscode.ExtensionContext) - { - this.context = context; - - // Top level, add a new group - vscode.commands.registerCommand('vs_tab_groups.addTabGroup', () => this.addTabGroup()); - vscode.commands.registerCommand('vs_tab_groups.removeAllGroups', () => this.removeAllGroups()); - - // Mid level, actions on tab groups - vscode.commands.registerCommand('vs_tab_groups.addEntry', (item) => this.addEntry(item)); - vscode.commands.registerCommand('vs_tab_groups.openTabGroup', (item) => this.openTabGroup(item)); - vscode.commands.registerCommand('vs_tab_groups.closeTabGroup', (item) => this.closeTabGroup(item)); - vscode.commands.registerCommand('vs_tab_groups.editTabGroupIcon', (item) => this.editTabGroupIcon(item)); - vscode.commands.registerCommand('vs_tab_groups.removeTabGroup', (item) => this.removeTabGroup(item)); - - // Low level, actions on tabs - vscode.commands.registerCommand('vs_tab_groups.openTab', (item) => this.openTab(item)); - vscode.commands.registerCommand('vs_tab_groups.closeTab', (item) => this.closeTab(item)); - vscode.commands.registerCommand('vs_tab_groups.removeTab', (item) => this.removeTab(item)); - - // General - vscode.commands.registerCommand('vs_tab_groups.item_clicked', (item) => this.item_clicked(item)); - } - - /** - * Save this tree in the workspace. - */ - public async save() - { - var treeData: any = {} - - // loop the data in this tree and pass it all to json format - for (let i = 0; i < this.m_data.length; i++) { - const itemJSON = await this.m_data[i].toJSON(); - treeData[`key_${i}`] = itemJSON; - } - - this.context.workspaceState.update('treeData', treeData) - } - - /** - * Load a tree from the workspace. - */ - public async load() - { - const treeData: any = this.context.workspaceState.get('treeData') - - for (let key in treeData) { - let objValue = treeData[key]; - const item = await tree_item.fromJSON(objValue) - this.m_data.push( item ) - } - - this.m_onDidChangeTreeData.fire(undefined); - } - - /** - * @inheritDoc - */ - public getTreeItem(item: tree_item): vscode.TreeItem|Thenable - { - let title = item.label ? item.label.toString() : ""; - let result = new vscode.TreeItem(title, item.collapsibleState); - // here we add our command which executes our memberfunction - result.command = { command: 'vs_tab_groups.item_clicked', title : title, arguments: [item] }; - result.contextValue = item.isRoot ? "vstg_root_item" : "vstg_child_item"; - result.iconPath = item.iconPath - return result; - } - - /** - * @inheritDoc - */ - public getChildren(element : tree_item | undefined): vscode.ProviderResult - { - if (element === undefined) { - return this.m_data; - } else { - return element.children; - } - } - - /*** TOP LEVEL ***/ - - /** - * Check if a label already exists for a parent of this tree - * @param label The label to check for - * @returns True if an item already uses this label, false if otherwise. - */ - labelExists(label: string) : boolean - { - for (let item of this.m_data) { - if (item.label === label) { - return true; - } - } - return false; - } - - /** - * Create a new group of tabs. - */ - async addTabGroup() - { - const input = await vscode.window.showInputBox({ - prompt:"Type in the name of the tab group to be created.\n" - }); - - if (input && input !== "") { - if (this.labelExists(input)) { - vscode.window.showErrorMessage(`Can not have two tab groups with name '${input}'`); - return - } - this.m_data.push(new tree_item(input, null, true)); - this.m_onDidChangeTreeData.fire(undefined); - } - - this.save() - } - - /** - * Remove all groups of tabs. Fully clears the tree. - */ - removeAllGroups() - { - vscode.window - .showInformationMessage("Are you sure you want to remove all groups?", "Yes", "No") - .then(answer => { - if (answer === "Yes") { - this.m_data = []; - this.m_onDidChangeTreeData.fire(undefined); - - this.save() - } - }) - } - - /*** MID LEVEL ***/ - - /** - * Escape a string path pattern - * @param s A string representing a path - * @returns The path string in a format that is usable in a regex - */ - escapeRegExp(s: string) { - s = s.replace(/\\/g, '\\\\'); // escape all backslashes - s = s.replace(/\./g, '\\.'); // escape all dots - s = s.replace(/\//g, '\\\/'); // escape all forward slashes - s = s.replace(/\*/g, '.*'); // when used *, the user means anything, i.e., .* - return s - } - - /** - * Check if a path should be ignored - * @param rootPath The path pointing to the root - * @param pathsToIgnore The patterns to ignore - * @param currentPath The path to be checked - * @returns True if currentPath should be ignored. False if otherwise. - */ - shouldIgnorePath(rootPath: string, pathsToIgnore: string[], currentPath: string) { - for (let pattern of pathsToIgnore) { - var complete_path = path.join(rootPath, pattern) - complete_path = this.escapeRegExp(complete_path) - - const regex = new RegExp(complete_path, 'g') - if (regex.test(currentPath)) { - return true - } - } - return false; - } - - /** - * Get all files recursively from a directory - * @param workspaceDir The workspace directory. Used to remove common paths from the strings. - * @param pathsToIgnore The patterns to ignore - * @param dir The directory to search. - * @returns An array containing all files in the directory. - */ - traverseDir(workspaceDir: string, pathsToIgnore: string[], dir: string) { - var files: string[] = []; - fs.readdirSync(dir).forEach(async (file) => { - let fullPath = path.join(dir, file); - - // Check if path should be ignored - if (this.shouldIgnorePath(workspaceDir, pathsToIgnore, fullPath)){ - return - } - - if (fs.lstatSync(fullPath).isDirectory()) { - files = files.concat( this.traverseDir(workspaceDir, pathsToIgnore, fullPath)); - - } else { - files.push( fullPath.replace(workspaceDir + path.sep, "") ); - } - }) - return files - } - - /** - * Add an entry to a parent item. - * @param item The parent item. - */ - async addEntry(item: tree_item) - { - if (!vscode.window.activeTextEditor) { - vscode.window.showErrorMessage(`Could not find an open editor. Try to open a file first.`); - return - } - const currentWorkSpace = await vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri); - if (!currentWorkSpace) { - vscode.window.showErrorMessage(`No workspace has been found.`); - return - } - const workspaceDir = currentWorkSpace.uri.fsPath; - - var quickPickItems = [] - - // Get labels of all open tabs - vscode.window.tabGroups.all.forEach(group => group.tabs.forEach(tab => { - var label = tab.label - if (tab.input instanceof vscode.TabInputText) { - label = tab.input.uri.fsPath - label = label.replace(workspaceDir + path.sep, "") - } - quickPickItems.push( {"label" : label} ); - })) - - if (quickPickItems.length > 0) { - // make a separator for the 'Open Tabs' group - const tabsSeparator = { - label: 'Open Tabs', - kind: vscode.QuickPickItemKind.Separator // this is new - }; - - // put the 'Open Tabs' separator at the beginning - quickPickItems.unshift(tabsSeparator); - } - - const ignorePaths: string[] | undefined = vscode.workspace.getConfiguration('vs-tab-groups').get('ignorePaths') - const allFiles = this.traverseDir( - workspaceDir, - ignorePaths ? ignorePaths : [], - workspaceDir - ) - - if (allFiles.length > 0) { - // make a separator for the 'File' group - const fileSeparator = { - label: 'All Files', - kind: vscode.QuickPickItemKind.Separator - }; - - quickPickItems.push(fileSeparator); - - allFiles.forEach(fName => quickPickItems.push( {"label": fName} )) - } - - // Display the selection box - const filesSelections = await vscode.window.showQuickPick(quickPickItems, { - canPickMany: true, - placeHolder: "Select files" - }); - - // Add to tree if something was selected - if (filesSelections) { - for (let selectionObj of filesSelections) { - const label = selectionObj["label"]; - const file_path = workspaceDir + path.sep + label; - - const newChild = new tree_item(label, file_path, false); - if (item.label) { - newChild.setParentLabel(item.label?.toString()) - } - - item?.add_child(newChild); - } - this.m_onDidChangeTreeData.fire(undefined); - - // Save the tree to the context - this.save() - } - } - - /** - * Open the tab group, i.e., all files in the group, in the editor. - * @param item The item that represents the root of the group. - */ - async openTabGroup(item: tree_item) - { - // Check if the user wants the other tabs to be closed when opening a new group - if (vscode.workspace.getConfiguration('vs-tab-groups').get('closeTabsOnOpenGroup')) { - // close every open tab - await vscode.commands.executeCommand('workbench.action.closeAllEditors') - } - - // Open the editor for every child of this tree - for (let child of item.children) { - this.openEditor(child.file) - } - } - - /** - * Close the tab group, i.e., all files in the group, in the editor. - * @param item The item that represents the root of the group. - */ - closeTabGroup(item: tree_item) - { - var file_paths: string[] = [] - for (let child of item.children) { - if (child.file) { - file_paths.push(child.file) - } - } - this.closeEditor(file_paths) - } - - /** - * Change the icon of the group's root. - * @param item The item representing the root of the group. - */ - async editTabGroupIcon(item: tree_item) - { - const defaultEmojis = ["🟥", "🟧", "🟨", "🟩", "🟦", "🟪", "🟫", "⬛", "⬜"] - const icons = ["red", "orange", "yellow", "green", "blue", "purple", "brown", "black", "white"] - - const iconSelection = await vscode.window.showQuickPick(defaultEmojis, { - canPickMany: false, - placeHolder: "Select icon" - }); - - if (iconSelection) { - var index: number = defaultEmojis.indexOf(iconSelection, 0); - - const p: string = path.join(__filename, '..', '..', 'resources', `${icons[index]}_square.png`) - item.iconPath = { light: p, dark: p } - - this.m_onDidChangeTreeData.fire(undefined); - } - } - - /** - * Removes the tab group from the tree - * @param item The item representing the root of the group. - */ - removeTabGroup(item: tree_item) - { - var index: number = this.m_data.indexOf(item, 0); - if (index > -1) { - this.m_data.splice(index, 1); - - this.m_onDidChangeTreeData.fire(undefined); - - // Save the tree to the context - this.save() - } - } - - /*** LOW LEVEL ***/ - - /** - * Open a tab in the editor. - * @param item The item representing the file to be opened. - */ - openTab(item: tree_item) - { - this.openEditor(item.file) - } - - /** - * Close a tab in the editor. - * @param item The item representing the file to be closed. - */ - closeTab(item: tree_item) - { - if (item.file){ - this.closeEditor([item.file]) - } - } - - /** - * Remove a tab/file from the group. - * @param item The item representing the file to be removed from the group. - */ - removeTab(item: tree_item) - { - // Loop through every group in the tree - for (let tab_group of this.m_data) { - // If the group is not the parent of the item, continue - if (tab_group.label !== item.parentLabel) { - continue - } - // Find the index of the item in the group's children - var index: number = tab_group.children.indexOf(item, 0); - if (index > -1) { - tab_group.children.splice(index, 1); - - this.m_onDidChangeTreeData.fire(undefined); - - // Save the tree to the context - this.save() - - break - } - } - } - - /*** GENERAL ***/ - - /** - * Gets an open document for a file or opens a new one and returns it - * @param filePaths The paths of the files to open - * @returns An array of TextDocument with the documents for each of the file paths provided - */ - async getOpenDocuments(filePaths: string[]) { - var docs: vscode.TextDocument[] = [] - - filePaths.forEach(async fpath => { - var found = false - vscode.workspace.textDocuments.forEach(doc => { - if (doc.fileName === fpath) { - docs.push(doc) - found = true - return - } - }) - - if (!found) { - docs.push( await vscode.workspace.openTextDocument(fpath) ) - } - }) - return docs - } - - /** - * Open a file in the editor - * @param filePath The path of the file to be opened. - */ - async openEditor(filePath: string | null) - { - if (filePath === null || filePath === undefined) { - return; - } - - this.getOpenDocuments([filePath]) - .then( documents => { - if (documents.length > 0) { - vscode.window.showTextDocument(documents[0], {preview: false}) - .then() - .then(undefined, err => { - // This is most likely not a problem that needs solving. - // An error is thrown when multiple files are being opened at the same time - //console.error('An error has occurred while trying to show file :: ', err); - //vscode.window.showErrorMessage(`Failed to show document '${filePath}'.`); - }) - } - }) - .then(undefined, err => { - //console.error('An error has occurred while trying to open file :: ', err); - vscode.window.showErrorMessage(`Failed to open document '${filePath}'.`); - }) - } - - /** - * Gets the documents that have tabs open in the editor - * @param filePaths An array with the paths of files to check for - * @returns An array of TextDocument that are open in tabs - */ - getOpenDocmentsInWorkSpace(filePaths: string[] | null) { - if (!filePaths) { - return undefined - } - - var editorTabs: string[] = [] - - // Find all open tabs - vscode.window.tabGroups.all.forEach(group => group.tabs.forEach(tab => { - if (!(tab.input instanceof vscode.TabInputText)) { - return - } - - // Check if the tab is in the paths - if (filePaths.indexOf(tab.input.uri.fsPath) > -1) { - editorTabs.push(tab.input.uri.fsPath) - } - })) - - // None of the files is open in a tab - if (editorTabs.length === 0) { - return undefined - } - - return this.getOpenDocuments(editorTabs) - } - - /** - * Close files in the editor - * @param filePaths An array with the paths of the files to be closed. - */ - async closeEditor(filePaths: string[] | null) - { - var documents = await this.getOpenDocmentsInWorkSpace(filePaths); - if (!documents || documents.length === 0) { - return - } - - for(let doc of documents) { - await vscode.window.showTextDocument(doc, {preview: true, preserveFocus: false}) - await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); - } - } - - /** - * Open the clicked tab/file in the editor. - * @param item The item representing the tab/file that was clicked. - */ - item_clicked(item: tree_item) { - if (!item.isRoot) { - this.openEditor(item.file) - } - } - } -} \ No newline at end of file