diff --git a/book/vscode-ext/.gitignore b/book/vscode-ext/.gitignore new file mode 100644 index 000000000..c92a7d368 --- /dev/null +++ b/book/vscode-ext/.gitignore @@ -0,0 +1,3 @@ +out/ +node_modules/ +*.vsix diff --git a/book/vscode-ext/.vscodeignore b/book/vscode-ext/.vscodeignore new file mode 100644 index 000000000..765aa91e7 --- /dev/null +++ b/book/vscode-ext/.vscodeignore @@ -0,0 +1,8 @@ +.vscode/** +.vscode-test/** +src/** +.gitignore +tsconfig.json +**/*.map +**/*.ts +node_modules/** diff --git a/book/vscode-ext/package-lock.json b/book/vscode-ext/package-lock.json new file mode 100644 index 000000000..d084ed659 --- /dev/null +++ b/book/vscode-ext/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "mlsysbook-workbench", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mlsysbook-workbench", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.108.1", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.108.1.tgz", + "integrity": "sha512-DerV0BbSzt87TbrqmZ7lRDIYaMiqvP8tmJTzW2p49ZBVtGUnGAu2RGQd1Wv4XMzEVUpaHbsemVM5nfuQJj7H6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/book/vscode-ext/package.json b/book/vscode-ext/package.json new file mode 100644 index 000000000..cdec31130 --- /dev/null +++ b/book/vscode-ext/package.json @@ -0,0 +1,81 @@ +{ + "name": "mlsysbook-workbench", + "displayName": "MLSysBook Workbench", + "description": "Build, debug, validate, and publish the ML Systems textbook", + "version": "0.1.0", + "publisher": "mlsysbook", + "engines": { + "vscode": "^1.85.0" + }, + "categories": ["Other"], + "activationEvents": [ + "workspaceContains:book/binder" + ], + "main": "./out/extension.js", + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/vscode": "^1.85.0", + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + }, + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "mlsysbook", + "title": "MLSysBook", + "icon": "resources/icon.svg" + } + ] + }, + "views": { + "mlsysbook": [ + { "id": "mlsysbook.build", "name": "Build" }, + { "id": "mlsysbook.debug", "name": "Debug" }, + { "id": "mlsysbook.precommit", "name": "Pre-commit / Validate" }, + { "id": "mlsysbook.publish", "name": "Publish / Maintenance" } + ] + }, + "commands": [ + { "command": "mlsysbook.buildChapterHtml", "title": "MLSysBook: Build Chapter (HTML)" }, + { "command": "mlsysbook.buildChapterPdf", "title": "MLSysBook: Build Chapter (PDF)" }, + { "command": "mlsysbook.buildChapterEpub", "title": "MLSysBook: Build Chapter (EPUB)" }, + { "command": "mlsysbook.previewChapter", "title": "MLSysBook: Preview Chapter" }, + { "command": "mlsysbook.buildVolumeHtml", "title": "MLSysBook: Build Full Volume (HTML)" }, + { "command": "mlsysbook.buildVolumePdf", "title": "MLSysBook: Build Full Volume (PDF)" }, + { "command": "mlsysbook.buildVolumeEpub", "title": "MLSysBook: Build Full Volume (EPUB)" }, + { "command": "mlsysbook.debugVolumePdf", "title": "MLSysBook: Debug Volume PDF" }, + { "command": "mlsysbook.debugChapterSections", "title": "MLSysBook: Debug Chapter Sections" }, + { "command": "mlsysbook.precommitRunAll", "title": "MLSysBook: Run All Pre-commit Hooks" }, + { "command": "mlsysbook.precommitRunHook", "title": "MLSysBook: Run Pre-commit Hook" }, + { "command": "mlsysbook.runAction", "title": "MLSysBook: Run Action" }, + { "command": "mlsysbook.cleanArtifacts", "title": "MLSysBook: Clean Build Artifacts" }, + { "command": "mlsysbook.doctor", "title": "MLSysBook: Doctor (Health Check)" }, + { "command": "mlsysbook.buildGlossary", "title": "MLSysBook: Build Global Glossary" }, + { "command": "mlsysbook.compressImages", "title": "MLSysBook: Compress Images" }, + { "command": "mlsysbook.repoHealth", "title": "MLSysBook: Repo Health Check" }, + { "command": "mlsysbook.contextBuildHtml", "title": "MLSysBook: Build as HTML" }, + { "command": "mlsysbook.contextBuildPdf", "title": "MLSysBook: Build as PDF" }, + { "command": "mlsysbook.contextBuildEpub", "title": "MLSysBook: Build as EPUB" }, + { "command": "mlsysbook.contextPreview", "title": "MLSysBook: Preview" }, + { "command": "mlsysbook.contextDebugSections", "title": "MLSysBook: Debug Sections" }, + { "command": "mlsysbook.refreshBuildTree", "title": "Refresh", "icon": "$(refresh)" } + ], + "menus": { + "explorer/context": [ + { "command": "mlsysbook.contextBuildHtml", "when": "resourceExtname == .qmd", "group": "mlsysbook@1" }, + { "command": "mlsysbook.contextBuildPdf", "when": "resourceExtname == .qmd", "group": "mlsysbook@2" }, + { "command": "mlsysbook.contextBuildEpub", "when": "resourceExtname == .qmd", "group": "mlsysbook@3" }, + { "command": "mlsysbook.contextPreview", "when": "resourceExtname == .qmd", "group": "mlsysbook@4" }, + { "command": "mlsysbook.contextDebugSections", "when": "resourceExtname == .qmd", "group": "mlsysbook@5" } + ], + "view/title": [ + { "command": "mlsysbook.refreshBuildTree", "when": "view == mlsysbook.build", "group": "navigation" } + ] + } + } +} diff --git a/book/vscode-ext/resources/icon.svg b/book/vscode-ext/resources/icon.svg new file mode 100644 index 000000000..df524359c --- /dev/null +++ b/book/vscode-ext/resources/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/book/vscode-ext/src/commands/buildCommands.ts b/book/vscode-ext/src/commands/buildCommands.ts new file mode 100644 index 000000000..163b07fe9 --- /dev/null +++ b/book/vscode-ext/src/commands/buildCommands.ts @@ -0,0 +1,36 @@ +import * as vscode from 'vscode'; +import { VolumeId, BuildFormat } from '../types'; +import { getRepoRoot } from '../utils/workspace'; +import { runInTerminal } from '../utils/terminal'; + +export function registerBuildCommands(context: vscode.ExtensionContext): void { + const root = getRepoRoot(); + if (!root) { return; } + + // Chapter-level builds + for (const fmt of ['Html', 'Pdf', 'Epub'] as const) { + const fmtLower = fmt.toLowerCase() as BuildFormat; + context.subscriptions.push( + vscode.commands.registerCommand(`mlsysbook.buildChapter${fmt}`, (vol: VolumeId, chapter: string) => { + runInTerminal(`./book/binder ${fmtLower} ${chapter} --${vol} -v`, root); + }) + ); + } + + // Preview + context.subscriptions.push( + vscode.commands.registerCommand('mlsysbook.previewChapter', (vol: VolumeId, chapter: string) => { + runInTerminal(`./book/binder preview ${chapter}`, root); + }) + ); + + // Volume-level builds + for (const fmt of ['Html', 'Pdf', 'Epub'] as const) { + const fmtLower = fmt.toLowerCase() as BuildFormat; + context.subscriptions.push( + vscode.commands.registerCommand(`mlsysbook.buildVolume${fmt}`, (vol: VolumeId) => { + runInTerminal(`./book/binder ${fmtLower} --${vol} -v`, root); + }) + ); + } +} diff --git a/book/vscode-ext/src/commands/contextMenuCommands.ts b/book/vscode-ext/src/commands/contextMenuCommands.ts new file mode 100644 index 000000000..17c0aa8bd --- /dev/null +++ b/book/vscode-ext/src/commands/contextMenuCommands.ts @@ -0,0 +1,43 @@ +import * as vscode from 'vscode'; +import { getRepoRoot, parseQmdFile } from '../utils/workspace'; +import { runInTerminal } from '../utils/terminal'; + +export function registerContextMenuCommands(context: vscode.ExtensionContext): void { + const root = getRepoRoot(); + if (!root) { return; } + + const makeHandler = (format: string) => { + return (uri: vscode.Uri) => { + const ctx = parseQmdFile(uri); + if (!ctx) { + vscode.window.showWarningMessage('Could not determine volume/chapter from file path.'); + return; + } + runInTerminal(`./book/binder ${format} ${ctx.chapter} --${ctx.volume} -v`, root); + }; + }; + + context.subscriptions.push( + vscode.commands.registerCommand('mlsysbook.contextBuildHtml', makeHandler('html')), + vscode.commands.registerCommand('mlsysbook.contextBuildPdf', makeHandler('pdf')), + vscode.commands.registerCommand('mlsysbook.contextBuildEpub', makeHandler('epub')), + + vscode.commands.registerCommand('mlsysbook.contextPreview', (uri: vscode.Uri) => { + const ctx = parseQmdFile(uri); + if (!ctx) { + vscode.window.showWarningMessage('Could not determine volume/chapter from file path.'); + return; + } + runInTerminal(`./book/binder preview ${ctx.chapter}`, root); + }), + + vscode.commands.registerCommand('mlsysbook.contextDebugSections', (uri: vscode.Uri) => { + const ctx = parseQmdFile(uri); + if (!ctx) { + vscode.window.showWarningMessage('Could not determine volume/chapter from file path.'); + return; + } + runInTerminal(`./book/binder debug pdf --${ctx.volume} --chapter ${ctx.chapter}`, root); + }), + ); +} diff --git a/book/vscode-ext/src/commands/debugCommands.ts b/book/vscode-ext/src/commands/debugCommands.ts new file mode 100644 index 000000000..e7de38738 --- /dev/null +++ b/book/vscode-ext/src/commands/debugCommands.ts @@ -0,0 +1,49 @@ +import * as vscode from 'vscode'; +import { VolumeId } from '../types'; +import { getRepoRoot } from '../utils/workspace'; +import { runInTerminal } from '../utils/terminal'; +import { discoverChapters } from '../utils/chapters'; + +export function registerDebugCommands(context: vscode.ExtensionContext): void { + const root = getRepoRoot(); + if (!root) { return; } + + // Debug full volume + context.subscriptions.push( + vscode.commands.registerCommand('mlsysbook.debugVolumePdf', (vol: VolumeId) => { + runInTerminal(`./book/binder debug pdf --${vol}`, root); + }) + ); + + // Debug chapter sections (interactive QuickPick) + context.subscriptions.push( + vscode.commands.registerCommand('mlsysbook.debugChapterSections', async () => { + const volPick = await vscode.window.showQuickPick( + [ + { label: 'Volume I', id: 'vol1' as VolumeId }, + { label: 'Volume II', id: 'vol2' as VolumeId }, + ], + { placeHolder: 'Select volume' }, + ); + if (!volPick) { return; } + + const volumes = discoverChapters(root); + const volume = volumes.find(v => v.id === volPick.id); + if (!volume) { return; } + + const chapterPick = await vscode.window.showQuickPick( + volume.chapters.map(ch => ({ label: ch.displayName, description: ch.name, id: ch.name })), + { placeHolder: 'Select chapter to debug' }, + ); + if (!chapterPick) { return; } + + const fmtPick = await vscode.window.showQuickPick( + ['pdf', 'html', 'epub'], + { placeHolder: 'Select format (default: pdf)' }, + ); + const fmt = fmtPick ?? 'pdf'; + + runInTerminal(`./book/binder debug ${fmt} --${volPick.id} --chapter ${chapterPick.id}`, root); + }) + ); +} diff --git a/book/vscode-ext/src/commands/precommitCommands.ts b/book/vscode-ext/src/commands/precommitCommands.ts new file mode 100644 index 000000000..75640c026 --- /dev/null +++ b/book/vscode-ext/src/commands/precommitCommands.ts @@ -0,0 +1,18 @@ +import * as vscode from 'vscode'; +import { getRepoRoot } from '../utils/workspace'; +import { runInTerminal } from '../utils/terminal'; + +export function registerPrecommitCommands(context: vscode.ExtensionContext): void { + const root = getRepoRoot(); + if (!root) { return; } + + context.subscriptions.push( + vscode.commands.registerCommand('mlsysbook.precommitRunAll', () => { + runInTerminal('pre-commit run --all-files', root); + }), + + vscode.commands.registerCommand('mlsysbook.precommitRunHook', (command: string) => { + runInTerminal(command, root); + }), + ); +} diff --git a/book/vscode-ext/src/commands/publishCommands.ts b/book/vscode-ext/src/commands/publishCommands.ts new file mode 100644 index 000000000..26358c668 --- /dev/null +++ b/book/vscode-ext/src/commands/publishCommands.ts @@ -0,0 +1,34 @@ +import * as vscode from 'vscode'; +import { getRepoRoot } from '../utils/workspace'; +import { runInTerminal } from '../utils/terminal'; + +export function registerPublishCommands(context: vscode.ExtensionContext): void { + const root = getRepoRoot(); + if (!root) { return; } + + // Generic action runner (used by tree items that pass a command string) + context.subscriptions.push( + vscode.commands.registerCommand('mlsysbook.runAction', (command: string) => { + runInTerminal(command, root); + }) + ); + + // Named aliases for command palette discoverability + context.subscriptions.push( + vscode.commands.registerCommand('mlsysbook.cleanArtifacts', () => { + runInTerminal('./book/binder clean', root); + }), + vscode.commands.registerCommand('mlsysbook.doctor', () => { + runInTerminal('./book/binder doctor', root); + }), + vscode.commands.registerCommand('mlsysbook.buildGlossary', () => { + runInTerminal('python3 book/tools/scripts/glossary/build_global_glossary.py', root); + }), + vscode.commands.registerCommand('mlsysbook.compressImages', () => { + runInTerminal('python3 book/tools/scripts/images/compress_images.py', root); + }), + vscode.commands.registerCommand('mlsysbook.repoHealth', () => { + runInTerminal('python3 book/tools/scripts/maintenance/repo_health_check.py --health-check', root); + }), + ); +} diff --git a/book/vscode-ext/src/constants.ts b/book/vscode-ext/src/constants.ts new file mode 100644 index 000000000..8c4e465ce --- /dev/null +++ b/book/vscode-ext/src/constants.ts @@ -0,0 +1,34 @@ +import { PrecommitHook, ActionDef } from './types'; + +export const PRECOMMIT_HOOKS: PrecommitHook[] = [ + { id: 'book-check-duplicate-labels', label: 'Check Duplicate Labels', command: 'pre-commit run book-check-duplicate-labels --all-files' }, + { id: 'book-check-unreferenced-labels', label: 'Check Unreferenced Labels', command: 'pre-commit run book-check-unreferenced-labels --all-files' }, + { id: 'book-validate-citations', label: 'Check Citations', command: 'pre-commit run book-validate-citations --all-files' }, + { id: 'book-check-references', label: 'Check References', command: 'pre-commit run book-check-references --all-files' }, + { id: 'book-validate-image-references', label: 'Check Image References', command: 'pre-commit run book-validate-image-references --all-files' }, + { id: 'book-validate-footnotes', label: 'Check Footnotes', command: 'pre-commit run book-validate-footnotes --all-files' }, + { id: 'book-check-forbidden-footnotes', label: 'Check Forbidden Footnotes', command: 'pre-commit run book-check-forbidden-footnotes --all-files' }, + { id: 'book-check-table-formatting', label: 'Check Table Formatting', command: 'pre-commit run book-check-table-formatting --all-files' }, + { id: 'book-check-unclosed-divs', label: 'Check Unclosed Divs', command: 'pre-commit run book-check-unclosed-divs --all-files' }, + { id: 'book-check-purpose-unnumbered', label: 'Check Purpose Unnumbered', command: 'pre-commit run book-check-purpose-unnumbered --all-files' }, + { id: 'book-format-python', label: 'Format Python in QMD', command: 'pre-commit run book-format-python --all-files' }, + { id: 'book-collapse-blank-lines', label: 'Collapse Blank Lines', command: 'pre-commit run book-collapse-blank-lines --all-files' }, + { id: 'codespell', label: 'Codespell', command: 'pre-commit run codespell --all-files' }, + { id: 'mdformat', label: 'Format Markdown', command: 'pre-commit run mdformat --all-files' }, + { id: 'bibtex-tidy', label: 'Tidy BibTeX', command: 'pre-commit run bibtex-tidy --all-files' }, +]; + +export const PUBLISH_ACTIONS: ActionDef[] = [ + { id: 'mit-press-vol1', label: 'MIT Press Vol1', command: 'bash book/tools/scripts/publish/mit-press-release.sh --vol1', icon: 'rocket' }, + { id: 'mit-press-vol2', label: 'MIT Press Vol2', command: 'bash book/tools/scripts/publish/mit-press-release.sh --vol2', icon: 'rocket' }, + { id: 'mit-press-vol1-copyedit', label: 'MIT Press Vol1 (Copy-edit)', command: 'bash book/tools/scripts/publish/mit-press-release.sh --vol1 --copyedit', icon: 'rocket' }, + { id: 'extract-figures-vol1', label: 'Extract Figures Vol1', command: 'python3 book/tools/scripts/publish/extract_figures.py --vol 1', icon: 'file-media' }, +]; + +export const MAINTENANCE_ACTIONS: ActionDef[] = [ + { id: 'clean', label: 'Clean Build Artifacts', command: './book/binder clean', icon: 'trash' }, + { id: 'doctor', label: 'Doctor (Health Check)', command: './book/binder doctor', icon: 'heart' }, + { id: 'glossary', label: 'Build Global Glossary', command: 'python3 book/tools/scripts/glossary/build_global_glossary.py', icon: 'book' }, + { id: 'compress-images', label: 'Compress Images', command: 'python3 book/tools/scripts/images/compress_images.py', icon: 'file-media' }, + { id: 'repo-health', label: 'Repo Health Check', command: 'python3 book/tools/scripts/maintenance/repo_health_check.py --health-check', icon: 'pulse' }, +]; diff --git a/book/vscode-ext/src/extension.ts b/book/vscode-ext/src/extension.ts new file mode 100644 index 000000000..5a3027f5e --- /dev/null +++ b/book/vscode-ext/src/extension.ts @@ -0,0 +1,48 @@ +import * as vscode from 'vscode'; +import { getRepoRoot } from './utils/workspace'; +import { BuildTreeProvider } from './providers/buildTreeProvider'; +import { DebugTreeProvider } from './providers/debugTreeProvider'; +import { PrecommitTreeProvider } from './providers/precommitTreeProvider'; +import { PublishTreeProvider } from './providers/publishTreeProvider'; +import { registerBuildCommands } from './commands/buildCommands'; +import { registerDebugCommands } from './commands/debugCommands'; +import { registerPrecommitCommands } from './commands/precommitCommands'; +import { registerPublishCommands } from './commands/publishCommands'; +import { registerContextMenuCommands } from './commands/contextMenuCommands'; + +export function activate(context: vscode.ExtensionContext): void { + const root = getRepoRoot(); + if (!root) { + vscode.window.showWarningMessage('MLSysBook Workbench: could not find repo root (book/binder not found).'); + return; + } + + // Tree view providers + const buildProvider = new BuildTreeProvider(root); + const debugProvider = new DebugTreeProvider(); + const precommitProvider = new PrecommitTreeProvider(); + const publishProvider = new PublishTreeProvider(); + + context.subscriptions.push( + vscode.window.createTreeView('mlsysbook.build', { treeDataProvider: buildProvider }), + vscode.window.createTreeView('mlsysbook.debug', { treeDataProvider: debugProvider }), + vscode.window.createTreeView('mlsysbook.precommit', { treeDataProvider: precommitProvider }), + vscode.window.createTreeView('mlsysbook.publish', { treeDataProvider: publishProvider }), + ); + + // Refresh command for build tree + context.subscriptions.push( + vscode.commands.registerCommand('mlsysbook.refreshBuildTree', () => buildProvider.refresh()), + ); + + // Register all command groups + registerBuildCommands(context); + registerDebugCommands(context); + registerPrecommitCommands(context); + registerPublishCommands(context); + registerContextMenuCommands(context); +} + +export function deactivate(): void { + // nothing to clean up +} diff --git a/book/vscode-ext/src/models/treeItems.ts b/book/vscode-ext/src/models/treeItems.ts new file mode 100644 index 000000000..f1a208ecd --- /dev/null +++ b/book/vscode-ext/src/models/treeItems.ts @@ -0,0 +1,42 @@ +import * as vscode from 'vscode'; +import { ChapterInfo, VolumeId } from '../types'; + +export class VolumeTreeItem extends vscode.TreeItem { + constructor( + public readonly volumeId: VolumeId, + label: string, + chapterCount: number, + ) { + super(label, vscode.TreeItemCollapsibleState.Collapsed); + this.description = `${chapterCount} chapters`; + this.contextValue = 'volume'; + this.iconPath = new vscode.ThemeIcon('book'); + } +} + +export class ChapterTreeItem extends vscode.TreeItem { + constructor(public readonly chapter: ChapterInfo) { + super(chapter.displayName, vscode.TreeItemCollapsibleState.Collapsed); + this.contextValue = 'chapter'; + this.iconPath = new vscode.ThemeIcon('file-text'); + this.tooltip = `${chapter.volume}/${chapter.name}`; + } +} + +export class ActionTreeItem extends vscode.TreeItem { + constructor( + label: string, + commandId: string, + commandArgs: unknown[], + icon?: string, + ) { + super(label, vscode.TreeItemCollapsibleState.None); + this.command = { + command: commandId, + title: label, + arguments: commandArgs, + }; + this.iconPath = new vscode.ThemeIcon(icon ?? 'play'); + this.contextValue = 'action'; + } +} diff --git a/book/vscode-ext/src/providers/buildTreeProvider.ts b/book/vscode-ext/src/providers/buildTreeProvider.ts new file mode 100644 index 000000000..c6ff5703b --- /dev/null +++ b/book/vscode-ext/src/providers/buildTreeProvider.ts @@ -0,0 +1,65 @@ +import * as vscode from 'vscode'; +import { VolumeInfo, BuildFormat } from '../types'; +import { discoverChapters } from '../utils/chapters'; +import { VolumeTreeItem, ChapterTreeItem, ActionTreeItem } from '../models/treeItems'; + +type TreeNode = VolumeTreeItem | ChapterTreeItem | ActionTreeItem; + +export class BuildTreeProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private volumes: VolumeInfo[] = []; + + constructor(private repoRoot: string) { + this.refresh(); + } + + refresh(): void { + this.volumes = discoverChapters(this.repoRoot); + this._onDidChangeTreeData.fire(undefined); + } + + getTreeItem(element: TreeNode): vscode.TreeItem { + return element; + } + + getChildren(element?: TreeNode): TreeNode[] { + if (!element) { + return this.volumes.map(v => + new VolumeTreeItem(v.id, v.label, v.chapters.length) + ); + } + + if (element instanceof VolumeTreeItem) { + const vol = this.volumes.find(v => v.id === element.volumeId); + if (!vol) { return []; } + + const chapterItems = vol.chapters.map(ch => new ChapterTreeItem(ch)); + + const formats: BuildFormat[] = ['html', 'pdf', 'epub']; + const volumeActions = formats.map(fmt => + new ActionTreeItem( + `Build Full ${vol.label} (${fmt.toUpperCase()})`, + `mlsysbook.buildVolume${fmt.charAt(0).toUpperCase() + fmt.slice(1)}`, + [vol.id], + 'package', + ) + ); + + return [...chapterItems, ...volumeActions]; + } + + if (element instanceof ChapterTreeItem) { + const ch = element.chapter; + return [ + new ActionTreeItem('Build HTML', 'mlsysbook.buildChapterHtml', [ch.volume, ch.name], 'globe'), + new ActionTreeItem('Build PDF', 'mlsysbook.buildChapterPdf', [ch.volume, ch.name], 'file-pdf'), + new ActionTreeItem('Build EPUB', 'mlsysbook.buildChapterEpub', [ch.volume, ch.name], 'book'), + new ActionTreeItem('Preview', 'mlsysbook.previewChapter', [ch.volume, ch.name], 'eye'), + ]; + } + + return []; + } +} diff --git a/book/vscode-ext/src/providers/debugTreeProvider.ts b/book/vscode-ext/src/providers/debugTreeProvider.ts new file mode 100644 index 000000000..287e098c2 --- /dev/null +++ b/book/vscode-ext/src/providers/debugTreeProvider.ts @@ -0,0 +1,19 @@ +import * as vscode from 'vscode'; +import { ActionTreeItem } from '../models/treeItems'; + +export class DebugTreeProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + getTreeItem(element: ActionTreeItem): vscode.TreeItem { + return element; + } + + getChildren(): ActionTreeItem[] { + return [ + new ActionTreeItem('Debug Vol1 PDF', 'mlsysbook.debugVolumePdf', ['vol1'], 'bug'), + new ActionTreeItem('Debug Vol2 PDF', 'mlsysbook.debugVolumePdf', ['vol2'], 'bug'), + new ActionTreeItem('Debug Chapter Sections...', 'mlsysbook.debugChapterSections', [], 'search'), + ]; + } +} diff --git a/book/vscode-ext/src/providers/precommitTreeProvider.ts b/book/vscode-ext/src/providers/precommitTreeProvider.ts new file mode 100644 index 000000000..b85028254 --- /dev/null +++ b/book/vscode-ext/src/providers/precommitTreeProvider.ts @@ -0,0 +1,27 @@ +import * as vscode from 'vscode'; +import { PRECOMMIT_HOOKS } from '../constants'; +import { ActionTreeItem } from '../models/treeItems'; + +export class PrecommitTreeProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + getTreeItem(element: ActionTreeItem): vscode.TreeItem { + return element; + } + + getChildren(): ActionTreeItem[] { + const runAll = new ActionTreeItem( + 'Run ALL Hooks', + 'mlsysbook.precommitRunAll', + [], + 'checklist', + ); + + const hookItems = PRECOMMIT_HOOKS.map(h => + new ActionTreeItem(h.label, 'mlsysbook.precommitRunHook', [h.command], 'play') + ); + + return [runAll, ...hookItems]; + } +} diff --git a/book/vscode-ext/src/providers/publishTreeProvider.ts b/book/vscode-ext/src/providers/publishTreeProvider.ts new file mode 100644 index 000000000..13736cc65 --- /dev/null +++ b/book/vscode-ext/src/providers/publishTreeProvider.ts @@ -0,0 +1,38 @@ +import * as vscode from 'vscode'; +import { PUBLISH_ACTIONS, MAINTENANCE_ACTIONS } from '../constants'; +import { ActionTreeItem } from '../models/treeItems'; + +type TreeNode = ActionTreeItem | SeparatorItem; + +class SeparatorItem extends vscode.TreeItem { + constructor(label: string) { + super(label, vscode.TreeItemCollapsibleState.None); + this.description = ''; + this.contextValue = 'separator'; + } +} + +export class PublishTreeProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + getTreeItem(element: TreeNode): vscode.TreeItem { + return element; + } + + getChildren(): TreeNode[] { + const publishItems = PUBLISH_ACTIONS.map(a => + new ActionTreeItem(a.label, 'mlsysbook.runAction', [a.command], a.icon ?? 'rocket') + ); + + const maintenanceItems = MAINTENANCE_ACTIONS.map(a => + new ActionTreeItem(a.label, 'mlsysbook.runAction', [a.command], a.icon ?? 'tools') + ); + + return [ + ...publishItems, + new SeparatorItem('--- Maintenance ---'), + ...maintenanceItems, + ]; + } +} diff --git a/book/vscode-ext/src/types.ts b/book/vscode-ext/src/types.ts new file mode 100644 index 000000000..40fd22309 --- /dev/null +++ b/book/vscode-ext/src/types.ts @@ -0,0 +1,33 @@ +export type VolumeId = 'vol1' | 'vol2'; +export type BuildFormat = 'html' | 'pdf' | 'epub'; + +export interface ChapterInfo { + name: string; + volume: VolumeId; + dirPath: string; + displayName: string; +} + +export interface VolumeInfo { + id: VolumeId; + label: string; + chapters: ChapterInfo[]; +} + +export interface PrecommitHook { + id: string; + label: string; + command: string; +} + +export interface ActionDef { + id: string; + label: string; + command: string; + icon?: string; +} + +export interface QmdFileContext { + volume: VolumeId; + chapter: string; +} diff --git a/book/vscode-ext/src/utils/chapters.ts b/book/vscode-ext/src/utils/chapters.ts new file mode 100644 index 000000000..73b12832d --- /dev/null +++ b/book/vscode-ext/src/utils/chapters.ts @@ -0,0 +1,45 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ChapterInfo, VolumeId, VolumeInfo } from '../types'; + +const EXCLUDED_DIRS = new Set(['frontmatter', 'backmatter', 'parts', 'glossary']); + +function toDisplayName(dirName: string): string { + return dirName + .split('_') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +export function discoverChapters(repoRoot: string): VolumeInfo[] { + const volumes: VolumeInfo[] = []; + + for (const vol of ['vol1', 'vol2'] as VolumeId[]) { + const contentsDir = path.join(repoRoot, 'book', 'quarto', 'contents', vol); + if (!fs.existsSync(contentsDir)) { continue; } + + const entries = fs.readdirSync(contentsDir, { withFileTypes: true }); + const chapters: ChapterInfo[] = entries + .filter(e => e.isDirectory() && !EXCLUDED_DIRS.has(e.name)) + .filter(e => { + // Must contain a .qmd file to count as a chapter + const dirPath = path.join(contentsDir, e.name); + return fs.readdirSync(dirPath).some(f => f.endsWith('.qmd')); + }) + .map(e => ({ + name: e.name, + volume: vol, + dirPath: path.join(contentsDir, e.name), + displayName: toDisplayName(e.name), + })) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + + volumes.push({ + id: vol, + label: vol === 'vol1' ? 'Volume I' : 'Volume II', + chapters, + }); + } + + return volumes; +} diff --git a/book/vscode-ext/src/utils/terminal.ts b/book/vscode-ext/src/utils/terminal.ts new file mode 100644 index 000000000..45d18ff78 --- /dev/null +++ b/book/vscode-ext/src/utils/terminal.ts @@ -0,0 +1,12 @@ +import * as vscode from 'vscode'; + +const TERMINAL_NAME = 'MLSysBook'; + +export function runInTerminal(command: string, cwd: string): void { + let terminal = vscode.window.terminals.find(t => t.name === TERMINAL_NAME); + if (!terminal) { + terminal = vscode.window.createTerminal({ name: TERMINAL_NAME, cwd }); + } + terminal.show(false); + terminal.sendText(command); +} diff --git a/book/vscode-ext/src/utils/workspace.ts b/book/vscode-ext/src/utils/workspace.ts new file mode 100644 index 000000000..79e8fdd8d --- /dev/null +++ b/book/vscode-ext/src/utils/workspace.ts @@ -0,0 +1,17 @@ +import * as vscode from 'vscode'; +import { QmdFileContext, VolumeId } from '../types'; + +export function getRepoRoot(): string | undefined { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { return undefined; } + return folders[0].uri.fsPath; +} + +export function parseQmdFile(uri: vscode.Uri): QmdFileContext | undefined { + const match = uri.fsPath.match(/contents\/(vol[12])\/([^/]+)\//); + if (!match) { return undefined; } + return { + volume: match[1] as VolumeId, + chapter: match[2], + }; +} diff --git a/book/vscode-ext/tsconfig.json b/book/vscode-ext/tsconfig.json new file mode 100644 index 000000000..2b95fa2de --- /dev/null +++ b/book/vscode-ext/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "outDir": "out", + "lib": ["ES2020"], + "sourceMap": true, + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "exclude": ["node_modules", ".vscode-test"] +}