import { ContainerModule } from '@theia/core/shared/inversify'; import { inject, injectable } from '@theia/core/shared/inversify'; import { Command, CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry, MessageService, } from '@theia/core/lib/common'; import { WidgetFactory, bindViewContribution, FrontendApplicationContribution } from '@theia/core/lib/browser'; import { QuickInputService } from '@theia/core/lib/common/quick-pick-service'; import { StorageService } from '@theia/core/lib/browser/storage-service'; import { BinaryBuffer } from '@theia/core/lib/common/buffer'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { WorkspaceCommands } from '@theia/workspace/lib/browser/workspace-commands'; import { EditorManager } from '@theia/editor/lib/browser/editor-manager'; import URI from '@theia/core/lib/common/uri'; import { ProjectTemplate, TEMPLATES, declaredSourceDirs, isValidModuleName, isValidProjectName, moduleNameOf, renderDefModule, renderImplModule, scaffoldFiles, validateProjectFile, } from './modula2-project'; import { MODULA2_EXPLORER_ID, Modula2ExplorerCommands, Modula2ExplorerContainerFactory, Modula2ExplorerContribution, Modula2ExplorerMenus, createModula2ExplorerWidget, } from './modula2-explorer-widget'; const CATEGORY = 'Modula-2'; const Commands = { newModule: Command.toLocalizedCommand({ id: 'modula2.newModule', label: 'New Module...', category: CATEGORY }), newDefModule: Command.toLocalizedCommand({ id: 'modula2.newDefModule', label: 'New Definition Module...', category: CATEGORY }), newProject: Command.toLocalizedCommand({ id: 'modula2.newProject', label: 'New Modula-2 Project...', category: CATEGORY }), openProject: Command.toLocalizedCommand({ id: 'modula2.openProject', label: 'Open Project...', category: CATEGORY }), switchProject: Command.toLocalizedCommand({ id: 'modula2.switchProject', label: 'Switch Project...', category: CATEGORY }), closeProject: Command.toLocalizedCommand({ id: 'modula2.closeProject', label: 'Close Project', category: CATEGORY }), validate: Command.toLocalizedCommand({ id: 'modula2.validateProject', label: 'Validate Project', category: CATEGORY }), }; @injectable() class ProjectCommands implements CommandContribution { @inject(FileService) protected readonly files: FileService; @inject(WorkspaceService) protected readonly workspace: WorkspaceService; @inject(EditorManager) protected readonly editors: EditorManager; @inject(MessageService) protected readonly messages: MessageService; @inject(QuickInputService) protected readonly quickInput: QuickInputService; @inject(CommandRegistry) protected readonly commands: CommandRegistry; @inject(StorageService) protected readonly storage: StorageService; registerCommands(commands: CommandRegistry): void { commands.registerCommand(Commands.newModule, { execute: () => this.newModule(false) }); commands.registerCommand(Commands.newDefModule, { execute: () => this.newModule(true) }); commands.registerCommand(Commands.newProject, { execute: () => this.newProject() }); commands.registerCommand(Commands.openProject, { execute: () => this.openProject() }); commands.registerCommand(Commands.switchProject, { execute: () => this.switchProject() }); commands.registerCommand(Commands.closeProject, { execute: () => this.closeProject() }); commands.registerCommand(Commands.validate, { execute: () => this.validateProject() }); } protected async root(): Promise { const roots = await this.workspace.roots; if (roots.length === 0) { this.messages.warn('Modula-2: open a folder first (File > Open Folder).'); return undefined; } return roots[0].resource; } /** Candidate source directories: declared ones that exist, then src/, then the root. */ protected async sourceDirChoices(root: URI): Promise { const declared: string[] = []; try { const content = await this.files.read(root.resolve('modula2.json')); declared.push(...declaredSourceDirs(content.value)); } catch { // No project file: fall through to conventions. } const out: string[] = []; for (const d of [...declared, 'src']) { if (d === '.' || d === '') continue; try { if (await this.files.exists(root.resolve(d))) out.push(d); } catch { /* ignore */ } } out.push('.'); return [...new Set(out)]; } protected async askModuleName(): Promise { return this.quickInput.input({ prompt: 'Module name (a Modula-2 identifier, e.g. MyModule)', placeHolder: 'MyModule', validateInput: async value => isValidModuleName(value.trim()) ?? null, }); } protected async newModule(defOnly: boolean): Promise { const root = await this.root(); if (!root) return; const raw = await this.askModuleName(); if (raw === undefined) return; const name = raw.trim(); let makeDef = defOnly; let makeMod = !defOnly; if (!defOnly) { const kind = await this.quickInput.showQuickPick( [{ label: 'Implementation + definition', description: 'MyModule.mod and MyModule.def', value: 'both' }, { label: 'Implementation only', description: 'MyModule.mod', value: 'mod' }, { label: 'Definition only', description: 'MyModule.def', value: 'def' }], { placeholder: 'What to create?' }, ); if (!kind) return; makeDef = kind.value !== 'mod'; makeMod = kind.value !== 'def'; } const choices = await this.sourceDirChoices(root); let dir = choices[0]; if (choices.length > 1) { const picked = await this.quickInput.showQuickPick( choices.map(c => ({ label: c === '.' ? '(project root)' : c, value: c })), { placeholder: 'Where to create the module?' }, ); if (!picked) return; dir = picked.value; } const created: string[] = []; const write = async (file: string, content: string): Promise => { const uri = dir === '.' ? root.resolve(file) : root.resolve(`${dir}/${file}`); try { if (await this.files.exists(uri)) { this.messages.warn(`Modula-2: ${file} already exists, skipped.`); return false; } await this.files.writeFile(uri, BinaryBuffer.fromString(content)); created.push(file); return true; } catch (error) { this.messages.error(`Modula-2: cannot write ${file}: ${error instanceof Error ? error.message : String(error)}`); return false; } }; if (makeMod) await write(`${name}.mod`, renderImplModule(name)); if (makeDef) await write(`${name}.def`, renderDefModule(name)); if (created.length === 0) return; this.messages.info(`Modula-2: created ${created.join(', ')}.`); const first = dir === '.' ? root.resolve(created[0]) : root.resolve(`${dir}/${created[0]}`); try { await this.editors.open(first, { mode: 'activate' }); } catch { /* created is enough */ } } protected async newProject(): Promise { const root = await this.root(); if (!root) return; const rawName = await this.quickInput.input({ prompt: 'Project name (used for the folder and modula2.json)', placeHolder: 'HelloWorld', validateInput: async value => isValidProjectName(value.trim()) ?? null, }); if (rawName === undefined) return; const name = rawName.trim(); const template = await this.quickInput.showQuickPick( TEMPLATES.map(t => ({ label: t.label, description: t.description, value: t.id as ProjectTemplate })), { placeholder: 'Project template' }, ); if (!template) return; // Scaffold at the root when it is empty, else in a subfolder. let target = root; try { const stat = await this.files.resolve(root); if ((stat.children ?? []).length > 0) target = root.resolve(name); } catch { target = root.resolve(name); } const files = scaffoldFiles(name, template.value); try { await this.files.createFolder(target); for (const f of files) { const uri = target.resolve(f.path); const slash = f.path.lastIndexOf('/'); if (slash > 0) await this.files.createFolder(uri.parent); if (await this.files.exists(uri)) { this.messages.warn(`Modula-2: ${f.path} already exists, skipped.`); continue; } await this.files.writeFile(uri, BinaryBuffer.fromString(f.content)); } } catch (error) { this.messages.error(`Modula-2: cannot scaffold project: ${error instanceof Error ? error.message : String(error)}`); return; } const where = target.toString() === root.toString() ? 'the open folder' : `the "${name}" folder`; this.messages.info(`Modula-2: created ${template.label} "${name}" in ${where}. Open modula2.json to review, then Modula-2: Build.`); } /** Open a project folder (delegates to the workspace folder dialog). */ protected async openProject(): Promise { await this.commands.executeCommand(WorkspaceCommands.OPEN_FOLDER.id); } /** Switch to a recently opened project (delegates to the recent picker). */ protected async switchProject(): Promise { await this.recordCurrentProject(); const recent = await this.recentProjects(); const picks = recent.map(r => ({ label: r.name, description: r.path, value: r.path, })); picks.push({ label: 'Browse for folder...', description: 'Open any folder as a project', value: '' }); const choice = await this.quickInput.showQuickPick(picks, { placeholder: 'Switch to project' }); if (!choice) return; if (!choice.value) { await this.commands.executeCommand(WorkspaceCommands.OPEN_FOLDER.id); return; } this.workspace.open(new URI(choice.value)); } /** Remembered Modula-2 projects (most recent first, max 12). */ protected async recentProjects(): Promise<{ name: string; path: string }[]> { try { const raw = await this.storage.getData<{ name: string; path: string }[]>('modula2.recentProjects', []); if (!Array.isArray(raw)) return []; const live: { name: string; path: string }[] = []; for (const r of raw) { if (!r || typeof r.name !== 'string' || typeof r.path !== 'string') continue; try { if (await this.files.exists(new URI(r.path))) live.push({ name: r.name, path: r.path }); } catch { /* drop dead entries */ } } return live.slice(0, 12); } catch { return []; } } /** Record the current workspace when it holds a Modula-2 project. */ protected async recordCurrentProject(): Promise { try { const roots = await this.workspace.roots; if (roots.length === 0) return; const root = roots[0].resource; const raw = await this.files.read(root.resolve('modula2.json')).catch(() => undefined); if (!raw) return; const json = JSON.parse(raw.value) as { name?: unknown }; if (typeof json.name !== 'string' || json.name.length === 0) return; const entry = { name: json.name, path: root.toString() }; const prev = await this.recentProjects(); const next = [entry, ...prev.filter(r => r.path !== entry.path)].slice(0, 12); await this.storage.setData('modula2.recentProjects', next); } catch { /* best-effort history */ } } /** Close the current project (delegates to workspace close). */ protected async closeProject(): Promise { await this.commands.executeCommand(WorkspaceCommands.CLOSE.id); } protected async validateProject(): Promise { const root = await this.root(); if (!root) return; const uri = root.resolve('modula2.json'); let text: string; try { text = (await this.files.read(uri)).value; } catch { this.messages.warn('Modula-2: no modula2.json in the open folder. Create one with Modula-2: New Modula-2 Project.'); return; } const problems = validateProjectFile(text); if (problems.length > 0) { this.messages.error(`Modula-2: invalid project.\n${problems.join('\n')}`); return; } // Existence checks against the workspace. let raw: { name: string; main: string; sourceDirectories?: string[]; definitionDirectories?: string[] }; try { raw = JSON.parse(text); } catch { this.messages.error('Modula-2: invalid project.\nmodula2.json: invalid JSON.'); return; } const missing: string[] = []; try { if (!await this.files.exists(root.resolve(raw.main))) missing.push(`main file "${raw.main}" does not exist`); } catch { /* ignore */ } for (const key of ['sourceDirectories', 'definitionDirectories'] as const) { for (const d of raw[key] ?? []) { try { if (!await this.files.exists(root.resolve(d))) missing.push(`"${key}" entry "${d}" does not exist`); } catch { /* ignore */ } } } if (missing.length > 0) { this.messages.error(`Modula-2: project "${raw.name}" has problems.\n${missing.join('\n')}`); return; } this.messages.info(`Modula-2: project "${raw.name}" is valid (main: ${raw.main}).`); } } class ProjectMenus implements MenuContribution { registerMenus(menus: MenuModelRegistry): void { const submenu = ['menubar', 'modula2']; menus.registerMenuAction(submenu, { commandId: Commands.newModule.id, order: '20' }); menus.registerMenuAction(submenu, { commandId: Commands.newDefModule.id, order: '21' }); menus.registerMenuAction(submenu, { commandId: Commands.newProject.id, order: '22' }); menus.registerMenuAction(submenu, { commandId: Commands.validate.id, order: '23' }); const project = ['menubar', 'project']; menus.registerSubmenu(project, 'Project'); menus.registerMenuAction(project, { commandId: Commands.newProject.id, order: '10' }); menus.registerMenuAction(project, { commandId: Commands.newModule.id, order: '11' }); menus.registerMenuAction(project, { commandId: Commands.newDefModule.id, order: '12' }); menus.registerMenuAction(project, { commandId: Commands.openProject.id, order: '13' }); menus.registerMenuAction(project, { commandId: Commands.switchProject.id, order: '14' }); menus.registerMenuAction(project, { commandId: Commands.closeProject.id, order: '15' }); } } export default new ContainerModule(bind => { bind(CommandContribution).to(ProjectCommands).inSingletonScope(); bind(MenuContribution).to(ProjectMenus).inSingletonScope(); bindViewContribution(bind, Modula2ExplorerContribution); bind(FrontendApplicationContribution).toService(Modula2ExplorerContribution); bind(WidgetFactory).toDynamicValue(context => ({ id: MODULA2_EXPLORER_ID, createWidget: () => createModula2ExplorerWidget(context.container), })); bind(Modula2ExplorerContainerFactory).toSelf().inSingletonScope(); bind(WidgetFactory).toService(Modula2ExplorerContainerFactory); bind(CommandContribution).to(Modula2ExplorerCommands).inSingletonScope(); bind(MenuContribution).to(Modula2ExplorerMenus).inSingletonScope(); });