From 0c78d336cda490754b61d1ee397b25f554d63bd7 Mon Sep 17 00:00:00 2001 From: Nemikolh Date: Wed, 7 Aug 2024 10:39:35 +0100 Subject: [PATCH 1/6] feat(extension): support config based ordering and refresh view on file changes --- extensions/vscode/package.json | 4 - .../vscode/src/commands/tutorialkit.add.ts | 16 +- .../src/commands/tutorialkit.load-tutorial.ts | 11 +- extensions/vscode/src/models/Lesson.ts | 15 -- extensions/vscode/src/models/Node.ts | 60 +++++++ .../vscode/src/models/tree/constants.ts | 3 + extensions/vscode/src/models/tree/load.ts | 149 ++++++++++++++++++ extensions/vscode/src/utils/uri.ts | 16 ++ extensions/vscode/src/views/lessonsTree.ts | 118 +++++++------- extensions/vscode/tsconfig.json | 3 +- 10 files changed, 302 insertions(+), 93 deletions(-) delete mode 100644 extensions/vscode/src/models/Lesson.ts create mode 100644 extensions/vscode/src/models/Node.ts create mode 100644 extensions/vscode/src/models/tree/constants.ts create mode 100644 extensions/vscode/src/models/tree/load.ts create mode 100644 extensions/vscode/src/utils/uri.ts diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 2360568e5..a03e660ab 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -119,10 +119,6 @@ ] }, "scripts": { - "__esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node", - "__dev": "pnpm run esbuild-base -- --sourcemap --watch", - "__vscode:prepublish": "pnpm run esbuild-base -- --minify", - "__build": "vsce package", "dev": "node scripts/build.mjs --watch", "build": "pnpm run check-types && node scripts/build.mjs", "check-types": "tsc --noEmit", diff --git a/extensions/vscode/src/commands/tutorialkit.add.ts b/extensions/vscode/src/commands/tutorialkit.add.ts index 860c8a270..d3c84279a 100644 --- a/extensions/vscode/src/commands/tutorialkit.add.ts +++ b/extensions/vscode/src/commands/tutorialkit.add.ts @@ -1,5 +1,5 @@ import { cmd } from '.'; -import { Lesson, LessonType } from '../models/Lesson'; +import { Node, NodeType } from '../models/Node'; import * as vscode from 'vscode'; let kebabCase: (string: string) => string; @@ -11,7 +11,7 @@ let capitalize: (string: string) => string; capitalize = module.capitalCase; })(); -export async function addLesson(parent: Lesson) { +export async function addLesson(parent: Node) { const lessonNumber = parent.children.length + 1; const lessonName = await getUnitName('lesson', lessonNumber); @@ -26,7 +26,7 @@ export async function addLesson(parent: Lesson) { return navigateToUnit(lessonFolderPath, 'lesson'); } -export async function addChapter(parent: Lesson) { +export async function addChapter(parent: Node) { const chapterNumber = parent.children.length + 1; const chapterName = await getUnitName('chapter', chapterNumber); @@ -38,7 +38,7 @@ export async function addChapter(parent: Lesson) { await cmd.refresh(); } -async function getUnitName(unitType: LessonType, unitNumber: number) { +async function getUnitName(unitType: NodeType, unitNumber: number) { const unitName = await vscode.window.showInputBox({ prompt: `Enter the name of the new ${unitType}`, value: `${capitalize(unitType)} ${unitNumber}`, @@ -52,19 +52,19 @@ async function getUnitName(unitType: LessonType, unitNumber: number) { return unitName; } -async function createUnitFolder(parentPath: string, unitNumber: number, unitName: string, unitType: LessonType) { - const unitFolderPath = `${parentPath}/${unitNumber}-${kebabCase(unitName)}`; +async function createUnitFolder(parentPath: vscode.Uri, unitNumber: number, unitName: string, unitType: NodeType) { + const unitFolderPath = `${unitNumber}-${kebabCase(unitName)}`; const metaFile = unitType === 'lesson' ? 'content.mdx' : 'meta.md'; await vscode.workspace.fs.writeFile( - vscode.Uri.file(`${unitFolderPath}/${metaFile}`), + vscode.Uri.joinPath(parentPath, unitFolderPath, metaFile), new TextEncoder().encode(`---\ntype: ${unitType}\ntitle: ${unitName}\n---\n`), ); return unitFolderPath; } -async function navigateToUnit(path: string, unitType: LessonType) { +async function navigateToUnit(path: string, unitType: NodeType) { const metaFile = unitType === 'lesson' ? 'content.mdx' : 'meta.md'; return cmd.goto(`${path}/${metaFile}`); diff --git a/extensions/vscode/src/commands/tutorialkit.load-tutorial.ts b/extensions/vscode/src/commands/tutorialkit.load-tutorial.ts index 3c7154df0..03f413f9d 100644 --- a/extensions/vscode/src/commands/tutorialkit.load-tutorial.ts +++ b/extensions/vscode/src/commands/tutorialkit.load-tutorial.ts @@ -1,15 +1,20 @@ import * as vscode from 'vscode'; import { extContext } from '../extension'; -import { LessonsTreeDataProvider, getLessonsTreeDataProvider, setLessonsTreeDataProvider } from '../views/lessonsTree'; +import { LessonsTreeDataProvider, setLessonsTreeDataProvider } from '../views/lessonsTree'; export async function loadTutorial(uri: vscode.Uri) { - setLessonsTreeDataProvider(new LessonsTreeDataProvider(uri, extContext)); + const treeDataProvider = new LessonsTreeDataProvider(uri, extContext); + + await treeDataProvider.init(); + + setLessonsTreeDataProvider(treeDataProvider); extContext.subscriptions.push( vscode.window.createTreeView('tutorialkit-lessons-tree', { - treeDataProvider: getLessonsTreeDataProvider(), + treeDataProvider, canSelectMany: true, }), + treeDataProvider, ); vscode.commands.executeCommand('setContext', 'tutorialkit:tree', true); diff --git a/extensions/vscode/src/models/Lesson.ts b/extensions/vscode/src/models/Lesson.ts deleted file mode 100644 index 487f8fbf2..000000000 --- a/extensions/vscode/src/models/Lesson.ts +++ /dev/null @@ -1,15 +0,0 @@ -export class Lesson { - constructor( - public name: string, - readonly path: string, - readonly children: Lesson[] = [], - public metadata?: { - _path: string; - title: string; - type: LessonType; - description?: string; - }, - ) {} -} - -export type LessonType = 'lesson' | 'chapter' | 'part'; diff --git a/extensions/vscode/src/models/Node.ts b/extensions/vscode/src/models/Node.ts new file mode 100644 index 000000000..0ce5ec134 --- /dev/null +++ b/extensions/vscode/src/models/Node.ts @@ -0,0 +1,60 @@ +import * as vscode from 'vscode'; +import type { TutorialSchema, PartSchema, ChapterSchema, LessonSchema } from '@tutorialkit/types'; + +export class Node { + /** + * Path to the meta.md / content.md file. + */ + metadataFilePath?: vscode.Uri; + + /** + * The metadata read from the metadata file. + */ + metadata?: Metadata; + + /** + * The number of expected children, populated on creation. + * If an order is specified but more folder are found, they + * are also included in that count but end up at the end of + * the tree. + */ + childCount: number = 0; + + /** + * The children of that node, loaded lazily. + */ + children: Node[] = []; + + /** + * If specified, describe the order of the children. + * When children are loaded, this should be used to sort + * them appropriately. + */ + order?: Map; + + get type() { + return this.metadata?.type; + } + + get name() { + if (this._customName) { + return this._customName; + } + + if (this.metadata && this.metadata.type !== 'tutorial') { + return this.metadata.title; + } + + return ''; + } + + constructor( + public folderName: string, + readonly path: vscode.Uri, + private _customName?: string, + ) {} +} + +export type Metadata = PartSchema | ChapterSchema | LessonSchema | TutorialSchema; + +export type NodeType = Metadata['type']; diff --git a/extensions/vscode/src/models/tree/constants.ts b/extensions/vscode/src/models/tree/constants.ts new file mode 100644 index 000000000..98e6b7948 --- /dev/null +++ b/extensions/vscode/src/models/tree/constants.ts @@ -0,0 +1,3 @@ +export const METADATA_FILES = new Set(['meta.md', 'meta.mdx', 'content.md', 'content.mdx']); +export const FILE_FOLDER = '_files'; +export const SOLUTION_FOLDER = '_solution'; diff --git a/extensions/vscode/src/models/tree/load.ts b/extensions/vscode/src/models/tree/load.ts new file mode 100644 index 000000000..6842e9414 --- /dev/null +++ b/extensions/vscode/src/models/tree/load.ts @@ -0,0 +1,149 @@ +import * as vscode from 'vscode'; +import grayMatter from 'gray-matter'; +import { Metadata, Node } from '../Node'; +import { METADATA_FILES, FILE_FOLDER, SOLUTION_FOLDER } from './constants'; +import { uriDirname } from '../../utils/uri'; + +export async function loadTutorialTree(tutorialFolderPath: vscode.Uri, tutorialName: string): Promise { + const metaFilePath = vscode.Uri.joinPath(tutorialFolderPath, 'meta.md'); + const tutorial = new Node('tutorial', tutorialFolderPath, tutorialName); + + await updateNodeFromMetadata(tutorial, metaFilePath); + await loadChildrenForNode(tutorial); + + return tutorial; +} + +export async function loadChildrenForNode(node: Node) { + if (node.type === 'lesson') { + return; + } + + if (node.childCount === node.children.length) { + return; + } + + node.children = await loadTutorialTreeFromBaseFolder(node.path); + + // sort children based on their order if defined in the metadata + const order = node.order; + + if (order) { + node.children.sort((a, b) => { + const aOrder = order.get(a.folderName); + const aOrderIsDefined = aOrder !== undefined; + const bOrder = order.get(b.folderName); + const bOrderIsDefined = bOrder !== undefined; + + if (aOrderIsDefined && bOrderIsDefined) { + return aOrder - bOrder; + } + + if (aOrderIsDefined) { + return -1; + } + + if (bOrderIsDefined) { + return 1; + } + + return a.folderName.localeCompare(b.folderName); + }); + } else { + node.children.sort((a, b) => a.folderName.localeCompare(b.folderName)); + } +} + +async function loadTutorialTreeFromBaseFolder(baseFolderPath: vscode.Uri): Promise { + const nodes: Node[] = []; + const files = await vscode.workspace.fs.readDirectory(baseFolderPath); + + await Promise.all( + files.map(async ([folderName, fileType]) => { + if (fileType !== vscode.FileType.Directory) { + return; + } + + const folderPath = vscode.Uri.joinPath(baseFolderPath, folderName); + const node = new Node(folderName, folderPath); + + // check if the folder directly includes one of the metadata files + const folderFiles = await vscode.workspace.fs.readDirectory(folderPath); + const [metadataFile] = folderFiles.find(([folderFile]) => METADATA_FILES.has(folderFile)) ?? []; + + if (metadataFile) { + await updateNodeFromMetadata(node, vscode.Uri.joinPath(folderPath, metadataFile)); + + nodes.push(node); + } + }), + ); + + return nodes; +} + +async function updateNodeFromMetadata(node: Node, metadataFilePath: vscode.Uri) { + const folderPath = uriDirname(metadataFilePath); + const metadataFileContent = await readFileContent(metadataFilePath); + const parsedContent = grayMatter(metadataFileContent); + + node.metadataFilePath = metadataFilePath; + node.metadata = parsedContent.data as Metadata; + node.childCount = node.type === 'lesson' ? 0 : await getChildCount(folderPath); + node.order = getOrder(node.metadata); +} + +function getOrder(metadata: Metadata): Map | undefined { + switch (metadata.type) { + case 'part': + return fromArrayToInversedMap(metadata.chapters); + case 'chapter': + return fromArrayToInversedMap(metadata.lessons); + case 'tutorial': + return fromArrayToInversedMap(metadata.parts); + default: + return; + } +} + +function fromArrayToInversedMap(arr: string[] | undefined): Map | undefined { + if (!arr) { + return; + } + + return new Map( + (function* () { + for (const [index, value] of arr.entries()) { + yield [value, index] as const; + } + })(), + ); +} + +async function getChildCount(nodeFolder: vscode.Uri): Promise { + const filesInFolder = await vscode.workspace.fs.readDirectory(nodeFolder); + + let childCount = 0; + + for (const [file, fileType] of filesInFolder) { + if (fileType !== vscode.FileType.Directory || file === FILE_FOLDER || file === SOLUTION_FOLDER) { + continue; + } + + childCount += 1; + } + + return childCount; +} + +async function readFileContent(filePath: vscode.Uri): Promise { + const document = vscode.workspace.textDocuments.find((document) => document.uri.toString() === filePath.toString()); + + if (document) { + return document.getText(); + } + + const binContent = await vscode.workspace.fs.readFile(filePath); + + return new TextDecoder().decode(binContent); +} diff --git a/extensions/vscode/src/utils/uri.ts b/extensions/vscode/src/utils/uri.ts new file mode 100644 index 000000000..5ccfe97ab --- /dev/null +++ b/extensions/vscode/src/utils/uri.ts @@ -0,0 +1,16 @@ +import * as vscode from 'vscode'; +import { dirname } from 'path'; + +export function uriDirname(uri: vscode.Uri): vscode.Uri { + if (uri.path.length === 0 || uri.path === '/') { + return uri; + } + + let path = dirname(uri.path); + + if (path.length === 1 && path.charCodeAt(0) === 46 /* CharCode.Period */) { + path = ''; + } + + return uri.with({ path }); +} diff --git a/extensions/vscode/src/views/lessonsTree.ts b/extensions/vscode/src/views/lessonsTree.ts index 43bae2268..58089cdcb 100644 --- a/extensions/vscode/src/views/lessonsTree.ts +++ b/extensions/vscode/src/views/lessonsTree.ts @@ -1,12 +1,10 @@ -import * as fs from 'fs'; -import grayMatter from 'gray-matter'; -import * as path from 'path'; import * as vscode from 'vscode'; +import path from 'path'; import { cmd } from '../commands'; -import { Lesson } from '../models/Lesson'; +import { Node } from '../models/Node'; import { getIcon } from '../utils/getIcon'; - -const metadataFiles = ['meta.md', 'meta.mdx', 'content.md', 'content.mdx']; +import { loadChildrenForNode, loadTutorialTree } from '../models/tree/load'; +import { METADATA_FILES } from '../models/tree/constants'; export const tutorialMimeType = 'application/tutorialkit.unit'; @@ -20,95 +18,91 @@ export function setLessonsTreeDataProvider(provider: LessonsTreeDataProvider) { lessonsTreeDataProvider = provider; } -export class LessonsTreeDataProvider implements vscode.TreeDataProvider { - private _lessons: Lesson[] = []; - private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); - readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; +export class LessonsTreeDataProvider implements vscode.TreeDataProvider, vscode.Disposable { + private _tutorial!: Node; + private _tutorialName: string; + private _onDidChangeTextDocumentDisposable: vscode.Disposable; + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; constructor( private readonly _workspaceRoot: vscode.Uri, private _context: vscode.ExtensionContext, ) { - this._loadLessons(); - } - - private _loadLessons(): void { - try { - const tutorialFolderPath = vscode.Uri.joinPath(this._workspaceRoot, 'src', 'content', 'tutorial').fsPath; - this._lessons = this._loadLessonsFromFolder(tutorialFolderPath); - } catch { - // do nothing - } - } - - private _loadLessonsFromFolder(folderPath: string): Lesson[] { - const lessons: Lesson[] = []; - const files = fs.readdirSync(folderPath); - - for (const file of files) { - const filePath = path.join(folderPath, file); - const stats = fs.statSync(filePath); + this._tutorialName = path.basename(_workspaceRoot.path); - if (stats.isDirectory()) { - const lessonName = path.basename(filePath); - const subLessons = this._loadLessonsFromFolder(filePath); - const lesson = new Lesson(lessonName, filePath, subLessons); + let timeoutId: ReturnType; + let loading = false; - // check if the folder directly includes one of the metadata files - const folderFiles = fs.readdirSync(filePath); - const metadataFile = folderFiles.find((folderFile) => metadataFiles.includes(folderFile)); + this._onDidChangeTextDocumentDisposable = vscode.workspace.onDidChangeTextDocument((documentChange) => { + if (loading || !METADATA_FILES.has(path.basename(documentChange.document.fileName))) { + return; + } - if (metadataFile) { - const metadataFilePath = path.join(filePath, metadataFile); - const metadataFileContent = fs.readFileSync(metadataFilePath, 'utf8'); - const parsedContent = grayMatter(metadataFileContent); + clearTimeout(timeoutId); - lesson.name = parsedContent.data.title; + timeoutId = setTimeout(async () => { + loading = true; + await this.refresh(); + loading = false; + }, 100); + }); + } - lesson.metadata = { - _path: metadataFilePath, - ...(parsedContent.data as any), - }; + dispose() { + this._onDidChangeTextDocumentDisposable.dispose(); + } - lessons.push(lesson); - } - } + async init() { + try { + const tutorialFolderPath = vscode.Uri.joinPath(this._workspaceRoot, 'src', 'content', 'tutorial'); + this._tutorial = await loadTutorialTree(tutorialFolderPath, this._tutorialName); + } catch { + // do nothing } - - return lessons; } - refresh(): void { - this._loadLessons(); + async refresh() { + await this.init(); this._onDidChangeTreeData.fire(undefined); } - getTreeItem(lesson: Lesson): vscode.TreeItem { - const treeItem = new vscode.TreeItem(lesson.name); + getTreeItem(node: Node): vscode.TreeItem { + const treeItem = new vscode.TreeItem(node.name); - treeItem.collapsibleState = - lesson.children.length > 0 ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; + if (node.type === 'tutorial') { + treeItem.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + } else if (node.childCount > 0) { + treeItem.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; + } else { + treeItem.collapsibleState = vscode.TreeItemCollapsibleState.None; + } - treeItem.contextValue = lesson.metadata?.type; + treeItem.contextValue = node.metadata?.type; treeItem.command = { command: cmd.goto.command, title: 'Go to the lesson', - arguments: [lesson.metadata?._path], + arguments: [node.metadataFilePath], }; - treeItem.iconPath = - lesson.metadata?.type === 'lesson' ? getIcon(this._context, 'lesson.svg') : getIcon(this._context, 'chapter.svg'); + if (node.metadata && node.type !== 'tutorial') { + treeItem.iconPath = + node.metadata.type === 'lesson' ? getIcon(this._context, 'lesson.svg') : getIcon(this._context, 'chapter.svg'); + } return treeItem; } - getChildren(element?: Lesson): Lesson[] { + async getChildren(element?: Node): Promise { if (element) { + await loadChildrenForNode(element); + return element.children; } - return this._lessons; + return [this._tutorial]; } } diff --git a/extensions/vscode/tsconfig.json b/extensions/vscode/tsconfig.json index e029bdc09..e5e55bb13 100644 --- a/extensions/vscode/tsconfig.json +++ b/extensions/vscode/tsconfig.json @@ -2,11 +2,12 @@ "extends": "../../tsconfig.json", "compilerOptions": { "allowJs": true, - "module": "Node16", + "module": "ES2022", "target": "ES2022", "outDir": "dist", "lib": ["ES2022"], "verbatimModuleSyntax": false, + "moduleResolution": "Bundler", "sourceMap": true, "rootDir": "." }, From 47c68d4c128a6a30307717871045e683336f33ee Mon Sep 17 00:00:00 2001 From: Nemikolh Date: Wed, 7 Aug 2024 11:08:02 +0100 Subject: [PATCH 2/6] feat: make it possible to add a part and delete anything --- extensions/vscode/package.json | 12 ++++++ extensions/vscode/src/commands/index.ts | 13 ++++-- .../vscode/src/commands/tutorialkit.add.ts | 43 ++++++++++--------- .../vscode/src/commands/tutorialkit.delete.ts | 9 ++++ .../vscode/src/commands/tutorialkit.goto.ts | 19 +++++++- .../vscode/src/models/tree/constants.ts | 2 +- extensions/vscode/src/models/tree/load.ts | 4 +- extensions/vscode/src/views/lessonsTree.ts | 2 +- 8 files changed, 74 insertions(+), 30 deletions(-) create mode 100644 extensions/vscode/src/commands/tutorialkit.delete.ts diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index a03e660ab..69dc8bbcd 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -37,6 +37,10 @@ "command": "tutorialkit.add-part", "title": "Add Part" }, + { + "command": "tutorialkit.delete", + "title": "Delete" + }, { "command": "tutorialkit.refresh", "title": "Refresh Lessons", @@ -100,6 +104,14 @@ { "command": "tutorialkit.add-chapter", "when": "view == tutorialkit-lessons-tree && viewItem == part" + }, + { + "command": "tutorialkit.add-part", + "when": "view == tutorialkit-lessons-tree && viewItem == tutorial" + }, + { + "command": "tutorialkit.delete", + "when": "view == tutorialkit-lessons-tree && (viewItem == chapter || viewItem == part || viewItem == lesson)" } ] }, diff --git a/extensions/vscode/src/commands/index.ts b/extensions/vscode/src/commands/index.ts index d7447dbfb..58ef86afa 100644 --- a/extensions/vscode/src/commands/index.ts +++ b/extensions/vscode/src/commands/index.ts @@ -1,10 +1,11 @@ import * as vscode from 'vscode'; +import { addChapter, addLesson, addPart } from './tutorialkit.add'; +import { deleteUnit } from './tutorialkit.delete'; import tutorialkitGoto from './tutorialkit.goto'; +import { initialize } from './tutorialkit.initialize'; +import { loadTutorial } from './tutorialkit.load-tutorial'; import tutorialkitRefresh from './tutorialkit.refresh'; -import { addChapter, addLesson } from './tutorialkit.add'; import { selectTutorial } from './tutorialkit.select-tutorial'; -import { loadTutorial } from './tutorialkit.load-tutorial'; -import { initialize } from './tutorialkit.initialize'; // no need to use these consts outside of this file, use `cmd[name].command` instead const CMD = { @@ -14,6 +15,8 @@ const CMD = { GOTO: 'tutorialkit.goto', ADD_LESSON: 'tutorialkit.add-lesson', ADD_CHAPTER: 'tutorialkit.add-chapter', + ADD_PART: 'tutorialkit.add-part', + DELETE: 'tutorialkit.delete', REFRESH: 'tutorialkit.refresh', } as const; @@ -25,6 +28,8 @@ export function useCommands() { vscode.commands.registerCommand(CMD.GOTO, tutorialkitGoto); vscode.commands.registerCommand(CMD.ADD_LESSON, addLesson); vscode.commands.registerCommand(CMD.ADD_CHAPTER, addChapter); + vscode.commands.registerCommand(CMD.ADD_PART, addPart); + vscode.commands.registerCommand(CMD.DELETE, deleteUnit); vscode.commands.registerCommand(CMD.REFRESH, tutorialkitRefresh); } @@ -34,7 +39,9 @@ export const cmd = { selectTutorial: createExecutor(CMD.SELECT_TUTORIAL), loadTutorial: createExecutor(CMD.LOAD_TUTORIAL), goto: createExecutor(CMD.GOTO), + delete: createExecutor(CMD.DELETE), addLesson: createExecutor(CMD.ADD_LESSON), + addPart: createExecutor(CMD.ADD_PART), addChapter: createExecutor(CMD.ADD_CHAPTER), refresh: createExecutor(CMD.REFRESH), }; diff --git a/extensions/vscode/src/commands/tutorialkit.add.ts b/extensions/vscode/src/commands/tutorialkit.add.ts index d3c84279a..c073a2f62 100644 --- a/extensions/vscode/src/commands/tutorialkit.add.ts +++ b/extensions/vscode/src/commands/tutorialkit.add.ts @@ -1,6 +1,7 @@ import { cmd } from '.'; import { Node, NodeType } from '../models/Node'; import * as vscode from 'vscode'; +import { FILES_FOLDER, SOLUTION_FOLDER } from '../models/tree/constants'; let kebabCase: (string: string) => string; let capitalize: (string: string) => string; @@ -12,30 +13,30 @@ let capitalize: (string: string) => string; })(); export async function addLesson(parent: Node) { - const lessonNumber = parent.children.length + 1; + const { folderPath, metaFilePath } = await createUnitFolder(parent, 'lesson'); - const lessonName = await getUnitName('lesson', lessonNumber); - - const lessonFolderPath = await createUnitFolder(parent.path, lessonNumber, lessonName, 'lesson'); - - await vscode.workspace.fs.createDirectory(vscode.Uri.file(`${lessonFolderPath}/_files`)); - await vscode.workspace.fs.createDirectory(vscode.Uri.file(`${lessonFolderPath}/_solution`)); + await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(folderPath, FILES_FOLDER)); + await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(folderPath, SOLUTION_FOLDER)); await cmd.refresh(); - return navigateToUnit(lessonFolderPath, 'lesson'); + return cmd.goto(metaFilePath); } export async function addChapter(parent: Node) { - const chapterNumber = parent.children.length + 1; + const { metaFilePath } = await createUnitFolder(parent, 'chapter'); - const chapterName = await getUnitName('chapter', chapterNumber); + await cmd.refresh(); - const chapterFolderPath = await createUnitFolder(parent.path, chapterNumber, chapterName, 'chapter'); + return cmd.goto(metaFilePath); +} - await navigateToUnit(chapterFolderPath, 'chapter'); +export async function addPart(parent: Node) { + const { metaFilePath } = await createUnitFolder(parent, 'part'); await cmd.refresh(); + + return cmd.goto(metaFilePath); } async function getUnitName(unitType: NodeType, unitNumber: number) { @@ -52,20 +53,20 @@ async function getUnitName(unitType: NodeType, unitNumber: number) { return unitName; } -async function createUnitFolder(parentPath: vscode.Uri, unitNumber: number, unitName: string, unitType: NodeType) { +async function createUnitFolder(parent: Node, unitType: NodeType) { + const unitNumber = parent.children.length + 1; + const unitName = await getUnitName(unitType, unitNumber); const unitFolderPath = `${unitNumber}-${kebabCase(unitName)}`; const metaFile = unitType === 'lesson' ? 'content.mdx' : 'meta.md'; + const metaFilePath = vscode.Uri.joinPath(parent.path, unitFolderPath, metaFile); await vscode.workspace.fs.writeFile( - vscode.Uri.joinPath(parentPath, unitFolderPath, metaFile), + metaFilePath, new TextEncoder().encode(`---\ntype: ${unitType}\ntitle: ${unitName}\n---\n`), ); - return unitFolderPath; -} - -async function navigateToUnit(path: string, unitType: NodeType) { - const metaFile = unitType === 'lesson' ? 'content.mdx' : 'meta.md'; - - return cmd.goto(`${path}/${metaFile}`); + return { + folderPath: vscode.Uri.joinPath(parent.path, unitFolderPath), + metaFilePath, + }; } diff --git a/extensions/vscode/src/commands/tutorialkit.delete.ts b/extensions/vscode/src/commands/tutorialkit.delete.ts new file mode 100644 index 000000000..23534806f --- /dev/null +++ b/extensions/vscode/src/commands/tutorialkit.delete.ts @@ -0,0 +1,9 @@ +import { cmd } from '.'; +import * as vscode from 'vscode'; +import { Node } from '../models/Node'; + +export async function deleteUnit(node: Node) { + await vscode.workspace.fs.delete(node.path, { recursive: true }); + + return cmd.refresh(); +} diff --git a/extensions/vscode/src/commands/tutorialkit.goto.ts b/extensions/vscode/src/commands/tutorialkit.goto.ts index 78b49ee89..3b37ff57c 100644 --- a/extensions/vscode/src/commands/tutorialkit.goto.ts +++ b/extensions/vscode/src/commands/tutorialkit.goto.ts @@ -1,11 +1,26 @@ import * as vscode from 'vscode'; -export default async (path: string | undefined) => { +export default async (path: string | vscode.Uri | undefined) => { if (!path) { return; } - const document = await vscode.workspace.openTextDocument(path); + /** + * This cast to 'any' makes no sense because if we narrow the type of path + * there are no type errors. So this code: + * + * ```ts + * typeof path === 'string' + * ? await vscode.workspace.openTextDocument(path) + * : await vscode.workspace.openTextDocument(path) + * ; + * ``` + * + * type check correctly despite doing nothing different on each branch. + * + * To avoid this TypeScript bug here we just cast to any. + */ + const document = await vscode.workspace.openTextDocument(path as any); await vscode.window.showTextDocument(document, { preserveFocus: true, diff --git a/extensions/vscode/src/models/tree/constants.ts b/extensions/vscode/src/models/tree/constants.ts index 98e6b7948..df6eb9741 100644 --- a/extensions/vscode/src/models/tree/constants.ts +++ b/extensions/vscode/src/models/tree/constants.ts @@ -1,3 +1,3 @@ export const METADATA_FILES = new Set(['meta.md', 'meta.mdx', 'content.md', 'content.mdx']); -export const FILE_FOLDER = '_files'; +export const FILES_FOLDER = '_files'; export const SOLUTION_FOLDER = '_solution'; diff --git a/extensions/vscode/src/models/tree/load.ts b/extensions/vscode/src/models/tree/load.ts index 6842e9414..fd8fc2342 100644 --- a/extensions/vscode/src/models/tree/load.ts +++ b/extensions/vscode/src/models/tree/load.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import grayMatter from 'gray-matter'; import { Metadata, Node } from '../Node'; -import { METADATA_FILES, FILE_FOLDER, SOLUTION_FOLDER } from './constants'; +import { METADATA_FILES, FILES_FOLDER, SOLUTION_FOLDER } from './constants'; import { uriDirname } from '../../utils/uri'; export async function loadTutorialTree(tutorialFolderPath: vscode.Uri, tutorialName: string): Promise { @@ -126,7 +126,7 @@ async function getChildCount(nodeFolder: vscode.Uri): Promise { let childCount = 0; for (const [file, fileType] of filesInFolder) { - if (fileType !== vscode.FileType.Directory || file === FILE_FOLDER || file === SOLUTION_FOLDER) { + if (fileType !== vscode.FileType.Directory || file === FILES_FOLDER || file === SOLUTION_FOLDER) { continue; } diff --git a/extensions/vscode/src/views/lessonsTree.ts b/extensions/vscode/src/views/lessonsTree.ts index 58089cdcb..f6b1d72c2 100644 --- a/extensions/vscode/src/views/lessonsTree.ts +++ b/extensions/vscode/src/views/lessonsTree.ts @@ -79,7 +79,7 @@ export class LessonsTreeDataProvider implements vscode.TreeDataProvider, v treeItem.collapsibleState = vscode.TreeItemCollapsibleState.None; } - treeItem.contextValue = node.metadata?.type; + treeItem.contextValue = node.type; treeItem.command = { command: cmd.goto.command, From 266fb8b4aa388794a3e4fae18f03fc1770519a46 Mon Sep 17 00:00:00 2001 From: Nemikolh Date: Wed, 7 Aug 2024 12:02:53 +0100 Subject: [PATCH 3/6] feat: make it possible to delete parts, chapters and lessons --- extensions/vscode/package.json | 7 ++++++ .../vscode/src/commands/tutorialkit.delete.ts | 13 +++++++++-- .../src/commands/tutorialkit.load-tutorial.ts | 17 +++++++------- .../src/commands/tutorialkit.refresh.ts | 2 +- extensions/vscode/src/global-state.ts | 22 +++++++++++++++++++ extensions/vscode/src/views/lessonsTree.ts | 10 --------- 6 files changed, 50 insertions(+), 21 deletions(-) create mode 100644 extensions/vscode/src/global-state.ts diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 69dc8bbcd..de35ac103 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -19,6 +19,13 @@ ], "main": "./dist/extension.js", "contributes": { + "keybindings":[ + { + "command": "tutorialkit.delete", + "key": "Shift+Backspace", + "when": "focusedView == tutorialkit-lessons-tree" + } + ], "commands": [ { "command": "tutorialkit.select-tutorial", diff --git a/extensions/vscode/src/commands/tutorialkit.delete.ts b/extensions/vscode/src/commands/tutorialkit.delete.ts index 23534806f..6011a43d1 100644 --- a/extensions/vscode/src/commands/tutorialkit.delete.ts +++ b/extensions/vscode/src/commands/tutorialkit.delete.ts @@ -1,9 +1,18 @@ import { cmd } from '.'; import * as vscode from 'vscode'; import { Node } from '../models/Node'; +import { getLessonsTreeView } from '../global-state'; -export async function deleteUnit(node: Node) { - await vscode.workspace.fs.delete(node.path, { recursive: true }); +export async function deleteUnit(selectedNode: Node | undefined, selectedNodes: Node[] | undefined) { + let nodes: readonly Node[] = (selectedNodes ? selectedNodes : [selectedNode]).filter((node) => node !== undefined); + + if (nodes.length === 0) { + nodes = getLessonsTreeView().selection; + } + + for (const node of nodes) { + await vscode.workspace.fs.delete(node.path, { recursive: true }); + } return cmd.refresh(); } diff --git a/extensions/vscode/src/commands/tutorialkit.load-tutorial.ts b/extensions/vscode/src/commands/tutorialkit.load-tutorial.ts index 03f413f9d..da1121b9b 100644 --- a/extensions/vscode/src/commands/tutorialkit.load-tutorial.ts +++ b/extensions/vscode/src/commands/tutorialkit.load-tutorial.ts @@ -1,21 +1,22 @@ import * as vscode from 'vscode'; import { extContext } from '../extension'; -import { LessonsTreeDataProvider, setLessonsTreeDataProvider } from '../views/lessonsTree'; +import { LessonsTreeDataProvider } from '../views/lessonsTree'; +import { setLessonsTreeDataProvider, setLessonsTreeView } from '../global-state'; export async function loadTutorial(uri: vscode.Uri) { const treeDataProvider = new LessonsTreeDataProvider(uri, extContext); await treeDataProvider.init(); + const treeView = vscode.window.createTreeView('tutorialkit-lessons-tree', { + treeDataProvider, + canSelectMany: true, + }); + setLessonsTreeDataProvider(treeDataProvider); + setLessonsTreeView(treeView); - extContext.subscriptions.push( - vscode.window.createTreeView('tutorialkit-lessons-tree', { - treeDataProvider, - canSelectMany: true, - }), - treeDataProvider, - ); + extContext.subscriptions.push(treeView, treeDataProvider); vscode.commands.executeCommand('setContext', 'tutorialkit:tree', true); } diff --git a/extensions/vscode/src/commands/tutorialkit.refresh.ts b/extensions/vscode/src/commands/tutorialkit.refresh.ts index 0110b6dda..f66aa9bb0 100644 --- a/extensions/vscode/src/commands/tutorialkit.refresh.ts +++ b/extensions/vscode/src/commands/tutorialkit.refresh.ts @@ -1,4 +1,4 @@ -import { getLessonsTreeDataProvider } from '../views/lessonsTree'; +import { getLessonsTreeDataProvider } from '../global-state'; export default () => { getLessonsTreeDataProvider().refresh(); diff --git a/extensions/vscode/src/global-state.ts b/extensions/vscode/src/global-state.ts new file mode 100644 index 000000000..16f846aaa --- /dev/null +++ b/extensions/vscode/src/global-state.ts @@ -0,0 +1,22 @@ +import type { TreeView } from 'vscode'; +import type { LessonsTreeDataProvider } from './views/lessonsTree'; +import type { Node } from './models/Node'; + +let lessonsTreeDataProvider: LessonsTreeDataProvider; +let lessonsTreeView: TreeView; + +export function getLessonsTreeDataProvider() { + return lessonsTreeDataProvider; +} + +export function getLessonsTreeView() { + return lessonsTreeView; +} + +export function setLessonsTreeDataProvider(provider: LessonsTreeDataProvider) { + lessonsTreeDataProvider = provider; +} + +export function setLessonsTreeView(treeView: TreeView) { + lessonsTreeView = treeView; +} diff --git a/extensions/vscode/src/views/lessonsTree.ts b/extensions/vscode/src/views/lessonsTree.ts index f6b1d72c2..c4ad555e8 100644 --- a/extensions/vscode/src/views/lessonsTree.ts +++ b/extensions/vscode/src/views/lessonsTree.ts @@ -8,16 +8,6 @@ import { METADATA_FILES } from '../models/tree/constants'; export const tutorialMimeType = 'application/tutorialkit.unit'; -let lessonsTreeDataProvider: LessonsTreeDataProvider; - -export function getLessonsTreeDataProvider() { - return lessonsTreeDataProvider; -} - -export function setLessonsTreeDataProvider(provider: LessonsTreeDataProvider) { - lessonsTreeDataProvider = provider; -} - export class LessonsTreeDataProvider implements vscode.TreeDataProvider, vscode.Disposable { private _tutorial!: Node; private _tutorialName: string; From 85be4656aa2c002b1d91916a7dcdf044007c989d Mon Sep 17 00:00:00 2001 From: Nemikolh Date: Wed, 7 Aug 2024 16:26:37 +0100 Subject: [PATCH 4/6] fix: respect the config ordering used when adding a new unit --- .../vscode/src/commands/tutorialkit.add.ts | 9 ++++- extensions/vscode/src/models/Node.ts | 23 ++++++++++++ extensions/vscode/src/models/tree/update.ts | 36 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 extensions/vscode/src/models/tree/update.ts diff --git a/extensions/vscode/src/commands/tutorialkit.add.ts b/extensions/vscode/src/commands/tutorialkit.add.ts index c073a2f62..f36e96f40 100644 --- a/extensions/vscode/src/commands/tutorialkit.add.ts +++ b/extensions/vscode/src/commands/tutorialkit.add.ts @@ -2,6 +2,7 @@ import { cmd } from '.'; import { Node, NodeType } from '../models/Node'; import * as vscode from 'vscode'; import { FILES_FOLDER, SOLUTION_FOLDER } from '../models/tree/constants'; +import { updateNodeMetadataInVFS } from '../models/tree/update'; let kebabCase: (string: string) => string; let capitalize: (string: string) => string; @@ -56,10 +57,16 @@ async function getUnitName(unitType: NodeType, unitNumber: number) { async function createUnitFolder(parent: Node, unitType: NodeType) { const unitNumber = parent.children.length + 1; const unitName = await getUnitName(unitType, unitNumber); - const unitFolderPath = `${unitNumber}-${kebabCase(unitName)}`; + const unitFolderPath = parent.order ? kebabCase(unitName) : `${unitNumber}-${kebabCase(unitName)}`; + const metaFile = unitType === 'lesson' ? 'content.mdx' : 'meta.md'; const metaFilePath = vscode.Uri.joinPath(parent.path, unitFolderPath, metaFile); + if (parent.order) { + parent.pushChild(unitFolderPath); + await updateNodeMetadataInVFS(parent); + } + await vscode.workspace.fs.writeFile( metaFilePath, new TextEncoder().encode(`---\ntype: ${unitType}\ntitle: ${unitName}\n---\n`), diff --git a/extensions/vscode/src/models/Node.ts b/extensions/vscode/src/models/Node.ts index 0ce5ec134..c432ae1f5 100644 --- a/extensions/vscode/src/models/Node.ts +++ b/extensions/vscode/src/models/Node.ts @@ -53,6 +53,29 @@ export class Node { readonly path: vscode.Uri, private _customName?: string, ) {} + + pushChild(folderPath: string) { + this.childCount += 1; + + if (this.order) { + this.order.set(folderPath, this.order.size); + + switch (this.metadata?.type) { + case 'chapter': { + this.metadata.lessons!.push(folderPath); + break; + } + case 'tutorial': { + this.metadata.parts!.push(folderPath); + break; + } + case 'part': { + this.metadata.chapters!.push(folderPath); + break; + } + } + } + } } export type Metadata = PartSchema | ChapterSchema | LessonSchema | TutorialSchema; diff --git a/extensions/vscode/src/models/tree/update.ts b/extensions/vscode/src/models/tree/update.ts new file mode 100644 index 000000000..0d3dd9308 --- /dev/null +++ b/extensions/vscode/src/models/tree/update.ts @@ -0,0 +1,36 @@ +import * as vscode from 'vscode'; +import grayMatter from 'gray-matter'; +import { Node } from '../Node'; + +export async function updateNodeMetadataInVFS(node: Node) { + if (!node.metadata || !node.metadataFilePath) { + return; + } + + const filePath = node.metadataFilePath; + const document = vscode.workspace.textDocuments.find((document) => document.uri.toString() === filePath.toString()); + + const content = document ? document.getText() : await readContentAsString(filePath); + + const parsedContent = grayMatter(content); + const frontMatterEnd = content.length - parsedContent.content.length; + const newMetadata = grayMatter.stringify('', node.metadata); + + if (document) { + const edit = new vscode.WorkspaceEdit(); + const range = new vscode.Range(document.positionAt(0), document.positionAt(frontMatterEnd)); + + edit.replace(filePath, range, newMetadata, { needsConfirmation: false, label: `Updated ${node.name}` }); + + await vscode.workspace.applyEdit(edit); + } else { + const newContent = new TextEncoder().encode(newMetadata + parsedContent.content); + await vscode.workspace.fs.writeFile(filePath, newContent); + } +} + +async function readContentAsString(filePath: vscode.Uri) { + const binContent = await vscode.workspace.fs.readFile(filePath); + + return new TextDecoder().decode(binContent); +} From 0f080fbc349f313b5f768aae47be432503bbf684 Mon Sep 17 00:00:00 2001 From: Nemikolh Date: Wed, 7 Aug 2024 16:31:21 +0100 Subject: [PATCH 5/6] fix: lint issues --- extensions/vscode/package.json | 2 +- extensions/vscode/src/commands/tutorialkit.goto.ts | 2 +- extensions/vscode/src/models/tree/load.ts | 12 ++++++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 9a8fe60f5..249ea4f5a 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -19,7 +19,7 @@ ], "main": "./dist/extension.js", "contributes": { - "keybindings":[ + "keybindings": [ { "command": "tutorialkit.delete", "key": "Shift+Backspace", diff --git a/extensions/vscode/src/commands/tutorialkit.goto.ts b/extensions/vscode/src/commands/tutorialkit.goto.ts index 3b37ff57c..efe72dfe3 100644 --- a/extensions/vscode/src/commands/tutorialkit.goto.ts +++ b/extensions/vscode/src/commands/tutorialkit.goto.ts @@ -16,7 +16,7 @@ export default async (path: string | vscode.Uri | undefined) => { * ; * ``` * - * type check correctly despite doing nothing different on each branch. + * Type check correctly despite doing nothing different on each branch. * * To avoid this TypeScript bug here we just cast to any. */ diff --git a/extensions/vscode/src/models/tree/load.ts b/extensions/vscode/src/models/tree/load.ts index fd8fc2342..64aa5a41c 100644 --- a/extensions/vscode/src/models/tree/load.ts +++ b/extensions/vscode/src/models/tree/load.ts @@ -95,14 +95,18 @@ async function updateNodeFromMetadata(node: Node, metadataFilePath: vscode.Uri) function getOrder(metadata: Metadata): Map | undefined { switch (metadata.type) { - case 'part': + case 'part': { return fromArrayToInversedMap(metadata.chapters); - case 'chapter': + } + case 'chapter': { return fromArrayToInversedMap(metadata.lessons); - case 'tutorial': + } + case 'tutorial': { return fromArrayToInversedMap(metadata.parts); - default: + } + default: { return; + } } } From 1c12d4f67d8a59cffc578295608ab7bc855eb801 Mon Sep 17 00:00:00 2001 From: Nemikolh Date: Thu, 8 Aug 2024 09:39:52 +0100 Subject: [PATCH 6/6] fix: code review --- extensions/vscode/src/commands/index.ts | 6 +- .../vscode/src/commands/tutorialkit.add.ts | 16 ++--- .../vscode/src/commands/tutorialkit.delete.ts | 19 +++++- .../vscode/src/commands/tutorialkit.goto.ts | 3 +- extensions/vscode/src/models/Node.ts | 58 +++++++++++++++++-- extensions/vscode/src/models/tree/load.ts | 6 +- extensions/vscode/src/utils/uri.ts | 16 ----- 7 files changed, 87 insertions(+), 37 deletions(-) delete mode 100644 extensions/vscode/src/utils/uri.ts diff --git a/extensions/vscode/src/commands/index.ts b/extensions/vscode/src/commands/index.ts index 58ef86afa..238a479b3 100644 --- a/extensions/vscode/src/commands/index.ts +++ b/extensions/vscode/src/commands/index.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { addChapter, addLesson, addPart } from './tutorialkit.add'; -import { deleteUnit } from './tutorialkit.delete'; +import { deleteNode } from './tutorialkit.delete'; import tutorialkitGoto from './tutorialkit.goto'; import { initialize } from './tutorialkit.initialize'; import { loadTutorial } from './tutorialkit.load-tutorial'; @@ -29,7 +29,7 @@ export function useCommands() { vscode.commands.registerCommand(CMD.ADD_LESSON, addLesson); vscode.commands.registerCommand(CMD.ADD_CHAPTER, addChapter); vscode.commands.registerCommand(CMD.ADD_PART, addPart); - vscode.commands.registerCommand(CMD.DELETE, deleteUnit); + vscode.commands.registerCommand(CMD.DELETE, deleteNode); vscode.commands.registerCommand(CMD.REFRESH, tutorialkitRefresh); } @@ -39,7 +39,7 @@ export const cmd = { selectTutorial: createExecutor(CMD.SELECT_TUTORIAL), loadTutorial: createExecutor(CMD.LOAD_TUTORIAL), goto: createExecutor(CMD.GOTO), - delete: createExecutor(CMD.DELETE), + delete: createExecutor(CMD.DELETE), addLesson: createExecutor(CMD.ADD_LESSON), addPart: createExecutor(CMD.ADD_PART), addChapter: createExecutor(CMD.ADD_CHAPTER), diff --git a/extensions/vscode/src/commands/tutorialkit.add.ts b/extensions/vscode/src/commands/tutorialkit.add.ts index f36e96f40..08b57a755 100644 --- a/extensions/vscode/src/commands/tutorialkit.add.ts +++ b/extensions/vscode/src/commands/tutorialkit.add.ts @@ -14,7 +14,7 @@ let capitalize: (string: string) => string; })(); export async function addLesson(parent: Node) { - const { folderPath, metaFilePath } = await createUnitFolder(parent, 'lesson'); + const { folderPath, metaFilePath } = await createNodeFolder(parent, 'lesson'); await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(folderPath, FILES_FOLDER)); await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(folderPath, SOLUTION_FOLDER)); @@ -25,7 +25,7 @@ export async function addLesson(parent: Node) { } export async function addChapter(parent: Node) { - const { metaFilePath } = await createUnitFolder(parent, 'chapter'); + const { metaFilePath } = await createNodeFolder(parent, 'chapter'); await cmd.refresh(); @@ -33,14 +33,14 @@ export async function addChapter(parent: Node) { } export async function addPart(parent: Node) { - const { metaFilePath } = await createUnitFolder(parent, 'part'); + const { metaFilePath } = await createNodeFolder(parent, 'part'); await cmd.refresh(); return cmd.goto(metaFilePath); } -async function getUnitName(unitType: NodeType, unitNumber: number) { +async function getNodeName(unitType: NodeType, unitNumber: number) { const unitName = await vscode.window.showInputBox({ prompt: `Enter the name of the new ${unitType}`, value: `${capitalize(unitType)} ${unitNumber}`, @@ -54,12 +54,12 @@ async function getUnitName(unitType: NodeType, unitNumber: number) { return unitName; } -async function createUnitFolder(parent: Node, unitType: NodeType) { +async function createNodeFolder(parent: Node, nodeType: NodeType) { const unitNumber = parent.children.length + 1; - const unitName = await getUnitName(unitType, unitNumber); + const unitName = await getNodeName(nodeType, unitNumber); const unitFolderPath = parent.order ? kebabCase(unitName) : `${unitNumber}-${kebabCase(unitName)}`; - const metaFile = unitType === 'lesson' ? 'content.mdx' : 'meta.md'; + const metaFile = nodeType === 'lesson' ? 'content.mdx' : 'meta.md'; const metaFilePath = vscode.Uri.joinPath(parent.path, unitFolderPath, metaFile); if (parent.order) { @@ -69,7 +69,7 @@ async function createUnitFolder(parent: Node, unitType: NodeType) { await vscode.workspace.fs.writeFile( metaFilePath, - new TextEncoder().encode(`---\ntype: ${unitType}\ntitle: ${unitName}\n---\n`), + new TextEncoder().encode(`---\ntype: ${nodeType}\ntitle: ${unitName}\n---\n`), ); return { diff --git a/extensions/vscode/src/commands/tutorialkit.delete.ts b/extensions/vscode/src/commands/tutorialkit.delete.ts index 6011a43d1..6d1830e02 100644 --- a/extensions/vscode/src/commands/tutorialkit.delete.ts +++ b/extensions/vscode/src/commands/tutorialkit.delete.ts @@ -2,17 +2,34 @@ import { cmd } from '.'; import * as vscode from 'vscode'; import { Node } from '../models/Node'; import { getLessonsTreeView } from '../global-state'; +import { updateNodeMetadataInVFS } from '../models/tree/update'; -export async function deleteUnit(selectedNode: Node | undefined, selectedNodes: Node[] | undefined) { +export async function deleteNode(selectedNode: Node | undefined, selectedNodes: Node[] | undefined) { let nodes: readonly Node[] = (selectedNodes ? selectedNodes : [selectedNode]).filter((node) => node !== undefined); if (nodes.length === 0) { nodes = getLessonsTreeView().selection; } + const parents = new Set(); + for (const node of nodes) { + if (node.parent) { + parents.add(node.parent); + node.parent.removeChild(node); + } + await vscode.workspace.fs.delete(node.path, { recursive: true }); } + // remove all nodes from parents that that might have been parent of other deleted nodes + for (const node of nodes) { + parents.delete(node); + } + + for (const parent of parents) { + await updateNodeMetadataInVFS(parent); + } + return cmd.refresh(); } diff --git a/extensions/vscode/src/commands/tutorialkit.goto.ts b/extensions/vscode/src/commands/tutorialkit.goto.ts index efe72dfe3..999fe96b3 100644 --- a/extensions/vscode/src/commands/tutorialkit.goto.ts +++ b/extensions/vscode/src/commands/tutorialkit.goto.ts @@ -16,7 +16,8 @@ export default async (path: string | vscode.Uri | undefined) => { * ; * ``` * - * Type check correctly despite doing nothing different on each branch. + * Type check correctly despite being identical to calling the function + * without the branch. * * To avoid this TypeScript bug here we just cast to any. */ diff --git a/extensions/vscode/src/models/Node.ts b/extensions/vscode/src/models/Node.ts index c432ae1f5..fdcbdbd78 100644 --- a/extensions/vscode/src/models/Node.ts +++ b/extensions/vscode/src/models/Node.ts @@ -25,6 +25,11 @@ export class Node { */ children: Node[] = []; + /** + * The parent of that node. + */ + parent?: Node; + /** * If specified, describe the order of the children. * When children are loaded, this should be used to sort @@ -54,30 +59,73 @@ export class Node { private _customName?: string, ) {} - pushChild(folderPath: string) { + pushChild(folderName: string) { this.childCount += 1; if (this.order) { - this.order.set(folderPath, this.order.size); + this.order.set(folderName, this.order.size); + + switch (this.metadata?.type) { + case 'chapter': { + this.metadata.lessons!.push(folderName); + break; + } + case 'tutorial': { + this.metadata.parts!.push(folderName); + break; + } + case 'part': { + this.metadata.chapters!.push(folderName); + break; + } + } + } + } + + removeChild(node: Node) { + if (!removeFromArray(this.children, node)) { + return; + } + if (this.order) { switch (this.metadata?.type) { case 'chapter': { - this.metadata.lessons!.push(folderPath); + removeFromArray(this.metadata.lessons!, node.folderName); break; } case 'tutorial': { - this.metadata.parts!.push(folderPath); + removeFromArray(this.metadata.parts!, node.folderName); break; } case 'part': { - this.metadata.chapters!.push(folderPath); + removeFromArray(this.metadata.chapters!, node.folderName); break; } } } } + + setChildren(children: Node[]) { + this.children = children; + + for (const child of this.children) { + child.parent = this; + } + } } export type Metadata = PartSchema | ChapterSchema | LessonSchema | TutorialSchema; export type NodeType = Metadata['type']; + +function removeFromArray(array: T[], element: T) { + const index = array.indexOf(element); + + if (index != -1) { + array.splice(index, 1); + + return true; + } + + return false; +} diff --git a/extensions/vscode/src/models/tree/load.ts b/extensions/vscode/src/models/tree/load.ts index 64aa5a41c..7153d4bb6 100644 --- a/extensions/vscode/src/models/tree/load.ts +++ b/extensions/vscode/src/models/tree/load.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import grayMatter from 'gray-matter'; import { Metadata, Node } from '../Node'; import { METADATA_FILES, FILES_FOLDER, SOLUTION_FOLDER } from './constants'; -import { uriDirname } from '../../utils/uri'; +import { Utils } from 'vscode-uri'; export async function loadTutorialTree(tutorialFolderPath: vscode.Uri, tutorialName: string): Promise { const metaFilePath = vscode.Uri.joinPath(tutorialFolderPath, 'meta.md'); @@ -23,7 +23,7 @@ export async function loadChildrenForNode(node: Node) { return; } - node.children = await loadTutorialTreeFromBaseFolder(node.path); + node.setChildren(await loadTutorialTreeFromBaseFolder(node.path)); // sort children based on their order if defined in the metadata const order = node.order; @@ -83,7 +83,7 @@ async function loadTutorialTreeFromBaseFolder(baseFolderPath: vscode.Uri): Promi } async function updateNodeFromMetadata(node: Node, metadataFilePath: vscode.Uri) { - const folderPath = uriDirname(metadataFilePath); + const folderPath = Utils.dirname(metadataFilePath); const metadataFileContent = await readFileContent(metadataFilePath); const parsedContent = grayMatter(metadataFileContent); diff --git a/extensions/vscode/src/utils/uri.ts b/extensions/vscode/src/utils/uri.ts deleted file mode 100644 index 5ccfe97ab..000000000 --- a/extensions/vscode/src/utils/uri.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as vscode from 'vscode'; -import { dirname } from 'path'; - -export function uriDirname(uri: vscode.Uri): vscode.Uri { - if (uri.path.length === 0 || uri.path === '/') { - return uri; - } - - let path = dirname(uri.path); - - if (path.length === 1 && path.charCodeAt(0) === 46 /* CharCode.Period */) { - path = ''; - } - - return uri.with({ path }); -}