modula2-project-frontend-module.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import { ContainerModule } from '@theia/core/shared/inversify';
  2. import { inject, injectable } from '@theia/core/shared/inversify';
  3. import {
  4. Command, CommandContribution, CommandRegistry, MenuContribution,
  5. MenuModelRegistry, MessageService,
  6. } from '@theia/core/lib/common';
  7. import { WidgetFactory, bindViewContribution, FrontendApplicationContribution } from '@theia/core/lib/browser';
  8. import { QuickInputService } from '@theia/core/lib/common/quick-pick-service';
  9. import { StorageService } from '@theia/core/lib/browser/storage-service';
  10. import { BinaryBuffer } from '@theia/core/lib/common/buffer';
  11. import { FileService } from '@theia/filesystem/lib/browser/file-service';
  12. import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
  13. import { WorkspaceCommands } from '@theia/workspace/lib/browser/workspace-commands';
  14. import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
  15. import URI from '@theia/core/lib/common/uri';
  16. import {
  17. ProjectTemplate, TEMPLATES, declaredSourceDirs, isValidModuleName,
  18. isValidProjectName, moduleNameOf, renderDefModule, renderImplModule,
  19. scaffoldFiles, validateProjectFile,
  20. } from './modula2-project';
  21. import {
  22. MODULA2_EXPLORER_ID, Modula2ExplorerCommands, Modula2ExplorerContainerFactory,
  23. Modula2ExplorerContribution, Modula2ExplorerMenus, createModula2ExplorerWidget,
  24. } from './modula2-explorer-widget';
  25. const CATEGORY = 'Modula-2';
  26. const Commands = {
  27. newModule: Command.toLocalizedCommand({ id: 'modula2.newModule', label: 'New Module...', category: CATEGORY }),
  28. newDefModule: Command.toLocalizedCommand({ id: 'modula2.newDefModule', label: 'New Definition Module...', category: CATEGORY }),
  29. newProject: Command.toLocalizedCommand({ id: 'modula2.newProject', label: 'New Modula-2 Project...', category: CATEGORY }),
  30. openProject: Command.toLocalizedCommand({ id: 'modula2.openProject', label: 'Open Project...', category: CATEGORY }),
  31. switchProject: Command.toLocalizedCommand({ id: 'modula2.switchProject', label: 'Switch Project...', category: CATEGORY }),
  32. closeProject: Command.toLocalizedCommand({ id: 'modula2.closeProject', label: 'Close Project', category: CATEGORY }),
  33. validate: Command.toLocalizedCommand({ id: 'modula2.validateProject', label: 'Validate Project', category: CATEGORY }),
  34. };
  35. @injectable()
  36. class ProjectCommands implements CommandContribution {
  37. @inject(FileService) protected readonly files: FileService;
  38. @inject(WorkspaceService) protected readonly workspace: WorkspaceService;
  39. @inject(EditorManager) protected readonly editors: EditorManager;
  40. @inject(MessageService) protected readonly messages: MessageService;
  41. @inject(QuickInputService) protected readonly quickInput: QuickInputService;
  42. @inject(CommandRegistry) protected readonly commands: CommandRegistry;
  43. @inject(StorageService) protected readonly storage: StorageService;
  44. registerCommands(commands: CommandRegistry): void {
  45. commands.registerCommand(Commands.newModule, { execute: () => this.newModule(false) });
  46. commands.registerCommand(Commands.newDefModule, { execute: () => this.newModule(true) });
  47. commands.registerCommand(Commands.newProject, { execute: () => this.newProject() });
  48. commands.registerCommand(Commands.openProject, { execute: () => this.openProject() });
  49. commands.registerCommand(Commands.switchProject, { execute: () => this.switchProject() });
  50. commands.registerCommand(Commands.closeProject, { execute: () => this.closeProject() });
  51. commands.registerCommand(Commands.validate, { execute: () => this.validateProject() });
  52. }
  53. protected async root(): Promise<URI | undefined> {
  54. const roots = await this.workspace.roots;
  55. if (roots.length === 0) {
  56. this.messages.warn('Modula-2: open a folder first (File > Open Folder).');
  57. return undefined;
  58. }
  59. return roots[0].resource;
  60. }
  61. /** Candidate source directories: declared ones that exist, then src/, then the root. */
  62. protected async sourceDirChoices(root: URI): Promise<string[]> {
  63. const declared: string[] = [];
  64. try {
  65. const content = await this.files.read(root.resolve('modula2.json'));
  66. declared.push(...declaredSourceDirs(content.value));
  67. } catch {
  68. // No project file: fall through to conventions.
  69. }
  70. const out: string[] = [];
  71. for (const d of [...declared, 'src']) {
  72. if (d === '.' || d === '') continue;
  73. try {
  74. if (await this.files.exists(root.resolve(d))) out.push(d);
  75. } catch { /* ignore */ }
  76. }
  77. out.push('.');
  78. return [...new Set(out)];
  79. }
  80. protected async askModuleName(): Promise<string | undefined> {
  81. return this.quickInput.input({
  82. prompt: 'Module name (a Modula-2 identifier, e.g. MyModule)',
  83. placeHolder: 'MyModule',
  84. validateInput: async value => isValidModuleName(value.trim()) ?? null,
  85. });
  86. }
  87. protected async newModule(defOnly: boolean): Promise<void> {
  88. const root = await this.root();
  89. if (!root) return;
  90. const raw = await this.askModuleName();
  91. if (raw === undefined) return;
  92. const name = raw.trim();
  93. let makeDef = defOnly;
  94. let makeMod = !defOnly;
  95. if (!defOnly) {
  96. const kind = await this.quickInput.showQuickPick(
  97. [{ label: 'Implementation + definition', description: 'MyModule.mod and MyModule.def', value: 'both' },
  98. { label: 'Implementation only', description: 'MyModule.mod', value: 'mod' },
  99. { label: 'Definition only', description: 'MyModule.def', value: 'def' }],
  100. { placeholder: 'What to create?' },
  101. );
  102. if (!kind) return;
  103. makeDef = kind.value !== 'mod';
  104. makeMod = kind.value !== 'def';
  105. }
  106. const choices = await this.sourceDirChoices(root);
  107. let dir = choices[0];
  108. if (choices.length > 1) {
  109. const picked = await this.quickInput.showQuickPick(
  110. choices.map(c => ({ label: c === '.' ? '(project root)' : c, value: c })),
  111. { placeholder: 'Where to create the module?' },
  112. );
  113. if (!picked) return;
  114. dir = picked.value;
  115. }
  116. const created: string[] = [];
  117. const write = async (file: string, content: string): Promise<boolean> => {
  118. const uri = dir === '.' ? root.resolve(file) : root.resolve(`${dir}/${file}`);
  119. try {
  120. if (await this.files.exists(uri)) {
  121. this.messages.warn(`Modula-2: ${file} already exists, skipped.`);
  122. return false;
  123. }
  124. await this.files.writeFile(uri, BinaryBuffer.fromString(content));
  125. created.push(file);
  126. return true;
  127. } catch (error) {
  128. this.messages.error(`Modula-2: cannot write ${file}: ${error instanceof Error ? error.message : String(error)}`);
  129. return false;
  130. }
  131. };
  132. if (makeMod) await write(`${name}.mod`, renderImplModule(name));
  133. if (makeDef) await write(`${name}.def`, renderDefModule(name));
  134. if (created.length === 0) return;
  135. this.messages.info(`Modula-2: created ${created.join(', ')}.`);
  136. const first = dir === '.' ? root.resolve(created[0]) : root.resolve(`${dir}/${created[0]}`);
  137. try {
  138. await this.editors.open(first, { mode: 'activate' });
  139. } catch { /* created is enough */ }
  140. }
  141. protected async newProject(): Promise<void> {
  142. const root = await this.root();
  143. if (!root) return;
  144. const rawName = await this.quickInput.input({
  145. prompt: 'Project name (used for the folder and modula2.json)',
  146. placeHolder: 'HelloWorld',
  147. validateInput: async value => isValidProjectName(value.trim()) ?? null,
  148. });
  149. if (rawName === undefined) return;
  150. const name = rawName.trim();
  151. const template = await this.quickInput.showQuickPick(
  152. TEMPLATES.map(t => ({ label: t.label, description: t.description, value: t.id as ProjectTemplate })),
  153. { placeholder: 'Project template' },
  154. );
  155. if (!template) return;
  156. // Scaffold at the root when it is empty, else in a subfolder.
  157. let target = root;
  158. try {
  159. const stat = await this.files.resolve(root);
  160. if ((stat.children ?? []).length > 0) target = root.resolve(name);
  161. } catch {
  162. target = root.resolve(name);
  163. }
  164. const files = scaffoldFiles(name, template.value);
  165. try {
  166. await this.files.createFolder(target);
  167. for (const f of files) {
  168. const uri = target.resolve(f.path);
  169. const slash = f.path.lastIndexOf('/');
  170. if (slash > 0) await this.files.createFolder(uri.parent);
  171. if (await this.files.exists(uri)) {
  172. this.messages.warn(`Modula-2: ${f.path} already exists, skipped.`);
  173. continue;
  174. }
  175. await this.files.writeFile(uri, BinaryBuffer.fromString(f.content));
  176. }
  177. } catch (error) {
  178. this.messages.error(`Modula-2: cannot scaffold project: ${error instanceof Error ? error.message : String(error)}`);
  179. return;
  180. }
  181. const where = target.toString() === root.toString() ? 'the open folder' : `the "${name}" folder`;
  182. this.messages.info(`Modula-2: created ${template.label} "${name}" in ${where}. Open modula2.json to review, then Modula-2: Build.`);
  183. }
  184. /** Open a project folder (delegates to the workspace folder dialog). */
  185. protected async openProject(): Promise<void> {
  186. await this.commands.executeCommand(WorkspaceCommands.OPEN_FOLDER.id);
  187. }
  188. /** Switch to a recently opened project (delegates to the recent picker). */
  189. protected async switchProject(): Promise<void> {
  190. await this.recordCurrentProject();
  191. const recent = await this.recentProjects();
  192. const picks = recent.map(r => ({
  193. label: r.name, description: r.path, value: r.path,
  194. }));
  195. picks.push({ label: 'Browse for folder...', description: 'Open any folder as a project', value: '' });
  196. const choice = await this.quickInput.showQuickPick(picks, { placeholder: 'Switch to project' });
  197. if (!choice) return;
  198. if (!choice.value) {
  199. await this.commands.executeCommand(WorkspaceCommands.OPEN_FOLDER.id);
  200. return;
  201. }
  202. this.workspace.open(new URI(choice.value));
  203. }
  204. /** Remembered Modula-2 projects (most recent first, max 12). */
  205. protected async recentProjects(): Promise<{ name: string; path: string }[]> {
  206. try {
  207. const raw = await this.storage.getData<{ name: string; path: string }[]>('modula2.recentProjects', []);
  208. if (!Array.isArray(raw)) return [];
  209. const live: { name: string; path: string }[] = [];
  210. for (const r of raw) {
  211. if (!r || typeof r.name !== 'string' || typeof r.path !== 'string') continue;
  212. try {
  213. if (await this.files.exists(new URI(r.path))) live.push({ name: r.name, path: r.path });
  214. } catch { /* drop dead entries */ }
  215. }
  216. return live.slice(0, 12);
  217. } catch {
  218. return [];
  219. }
  220. }
  221. /** Record the current workspace when it holds a Modula-2 project. */
  222. protected async recordCurrentProject(): Promise<void> {
  223. try {
  224. const roots = await this.workspace.roots;
  225. if (roots.length === 0) return;
  226. const root = roots[0].resource;
  227. const raw = await this.files.read(root.resolve('modula2.json')).catch(() => undefined);
  228. if (!raw) return;
  229. const json = JSON.parse(raw.value) as { name?: unknown };
  230. if (typeof json.name !== 'string' || json.name.length === 0) return;
  231. const entry = { name: json.name, path: root.toString() };
  232. const prev = await this.recentProjects();
  233. const next = [entry, ...prev.filter(r => r.path !== entry.path)].slice(0, 12);
  234. await this.storage.setData('modula2.recentProjects', next);
  235. } catch { /* best-effort history */ }
  236. }
  237. /** Close the current project (delegates to workspace close). */
  238. protected async closeProject(): Promise<void> {
  239. await this.commands.executeCommand(WorkspaceCommands.CLOSE.id);
  240. }
  241. protected async validateProject(): Promise<void> {
  242. const root = await this.root();
  243. if (!root) return;
  244. const uri = root.resolve('modula2.json');
  245. let text: string;
  246. try {
  247. text = (await this.files.read(uri)).value;
  248. } catch {
  249. this.messages.warn('Modula-2: no modula2.json in the open folder. Create one with Modula-2: New Modula-2 Project.');
  250. return;
  251. }
  252. const problems = validateProjectFile(text);
  253. if (problems.length > 0) {
  254. this.messages.error(`Modula-2: invalid project.\n${problems.join('\n')}`);
  255. return;
  256. }
  257. // Existence checks against the workspace.
  258. let raw: { name: string; main: string; sourceDirectories?: string[]; definitionDirectories?: string[] };
  259. try {
  260. raw = JSON.parse(text);
  261. } catch {
  262. this.messages.error('Modula-2: invalid project.\nmodula2.json: invalid JSON.');
  263. return;
  264. }
  265. const missing: string[] = [];
  266. try {
  267. if (!await this.files.exists(root.resolve(raw.main))) missing.push(`main file "${raw.main}" does not exist`);
  268. } catch { /* ignore */ }
  269. for (const key of ['sourceDirectories', 'definitionDirectories'] as const) {
  270. for (const d of raw[key] ?? []) {
  271. try {
  272. if (!await this.files.exists(root.resolve(d))) missing.push(`"${key}" entry "${d}" does not exist`);
  273. } catch { /* ignore */ }
  274. }
  275. }
  276. if (missing.length > 0) {
  277. this.messages.error(`Modula-2: project "${raw.name}" has problems.\n${missing.join('\n')}`);
  278. return;
  279. }
  280. this.messages.info(`Modula-2: project "${raw.name}" is valid (main: ${raw.main}).`);
  281. }
  282. }
  283. class ProjectMenus implements MenuContribution {
  284. registerMenus(menus: MenuModelRegistry): void {
  285. const submenu = ['menubar', 'modula2'];
  286. menus.registerMenuAction(submenu, { commandId: Commands.newModule.id, order: '20' });
  287. menus.registerMenuAction(submenu, { commandId: Commands.newDefModule.id, order: '21' });
  288. menus.registerMenuAction(submenu, { commandId: Commands.newProject.id, order: '22' });
  289. menus.registerMenuAction(submenu, { commandId: Commands.validate.id, order: '23' });
  290. const project = ['menubar', 'project'];
  291. menus.registerSubmenu(project, 'Project');
  292. menus.registerMenuAction(project, { commandId: Commands.newProject.id, order: '10' });
  293. menus.registerMenuAction(project, { commandId: Commands.newModule.id, order: '11' });
  294. menus.registerMenuAction(project, { commandId: Commands.newDefModule.id, order: '12' });
  295. menus.registerMenuAction(project, { commandId: Commands.openProject.id, order: '13' });
  296. menus.registerMenuAction(project, { commandId: Commands.switchProject.id, order: '14' });
  297. menus.registerMenuAction(project, { commandId: Commands.closeProject.id, order: '15' });
  298. }
  299. }
  300. export default new ContainerModule(bind => {
  301. bind(CommandContribution).to(ProjectCommands).inSingletonScope();
  302. bind(MenuContribution).to(ProjectMenus).inSingletonScope();
  303. bindViewContribution(bind, Modula2ExplorerContribution);
  304. bind(FrontendApplicationContribution).toService(Modula2ExplorerContribution);
  305. bind(WidgetFactory).toDynamicValue(context => ({
  306. id: MODULA2_EXPLORER_ID,
  307. createWidget: () => createModula2ExplorerWidget(context.container),
  308. }));
  309. bind(Modula2ExplorerContainerFactory).toSelf().inSingletonScope();
  310. bind(WidgetFactory).toService(Modula2ExplorerContainerFactory);
  311. bind(CommandContribution).to(Modula2ExplorerCommands).inSingletonScope();
  312. bind(MenuContribution).to(Modula2ExplorerMenus).inSingletonScope();
  313. });