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"]
+}