Ver Fonte

Project activity icon, menu and creation commands

Dedicated view container gives the Modula-2 Project view its
folder-library activity icon; the tree root sets height 100% so the
virtual list measures correctly inside container parts. New top-level
Project menubar menu (New/Open/Switch/Close) with a stored
recent-projects list behind Switch. Explorer and global creation
flows gain Definition-only modules plus New Folder Here, with
workspace-root fallback.

Validation: GUI end-to-end (cold-start tree, icon, switch picker,
folder/module/def creation with skeletons, auto-open).
Eric Streit há 5 dias atrás
pai
commit
80b5a1d3b7

+ 8 - 4
PLAN.md

@@ -1,13 +1,17 @@
-# Plan — next steps (from v0.27.0)
+# Plan — next steps (from v0.30.0)
 
-Status: IDE complete through v0.27.0, distributables fresh at v0.24.0
+Status: IDE complete through v0.30.0, distributables fresh at v0.24.0
 (AppImage + deb in gitignored `apps/electron/dist/`). Tree clean except
 this file; all tags local.
 
 ## Done since the last plan
 
-- v0.27.0 WITH completion (guard-record fields offered bare inside
-  `WITH` bodies, innermost first; GUI-verified suggest widget).
+- v0.29.0 explorer creation commands (definition-only modules,
+  New Folder Here, workspace-root fallback).
+- v0.30.0 project activity icon (dedicated view container; tree root
+  needs explicit height or the virtual list measures zero),
+  Project menubar menu (New/Open/Switch/Close), recent-projects list
+  with Switch picker, definition-only + folder creation (above).
 
 ## Next, in priority order
 

+ 39 - 1
extensions/modula2-project/src/browser/modula2-explorer-widget.ts

@@ -4,7 +4,8 @@ import {
   AbstractViewContribution, codicon, CompositeTreeNode,
   ContextMenuRenderer, createTreeContainer,
   ExpandableTreeNode, NodeProps, SelectableTreeNode,
-  TreeModel, TreeNode, TreeProps, TreeWidget, WidgetFactory, WidgetManager,
+  TreeModel, TreeNode, TreeProps, TreeWidget, ViewContainer, ViewContainerTitleOptions,
+  Widget, WidgetFactory, WidgetManager,
 } from '@theia/core/lib/browser';
 import { Command, CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry, MessageService } from '@theia/core/lib/common';
 import { FileService } from '@theia/filesystem/lib/browser/file-service';
@@ -71,6 +72,10 @@ export class Modula2ExplorerWidget extends TreeWidget {
     this.title.label = 'Modula-2 Project';
     this.title.caption = 'Modula-2 project files';
     this.title.closable = false;
+    // Fill the view-container part: without an explicit height the
+    // virtualized tree measures zero and renders nothing (a dock panel
+    // would size it instead).
+    this.node.style.height = '100%';
   }
 
   @postConstruct()
@@ -83,6 +88,10 @@ export class Modula2ExplorerWidget extends TreeWidget {
     this.toDispose.push(this.files.onDidFilesChange(() => this.scheduleRefresh()));
     this.toDispose.push(this.workspace.onWorkspaceChanged(() => this.refresh()));
     this.toDispose.push(this.workspace.onWorkspaceLocationChanged(() => this.refresh()));
+    // The view may be created before the workspace is ready (fresh view
+    // container at startup): refresh once roots resolve, in addition to
+    // the event-driven refreshes, so the tree never stays empty.
+    void this.workspace.roots.then(() => this.scheduleRefresh(), () => undefined);
     void this.refresh();
   }
 
@@ -214,9 +223,38 @@ export class Modula2ExplorerWidget extends TreeWidget {
   }
 }
 
+export const MODULA2_EXPLORER_VIEW_CONTAINER_ID = 'modula2-explorer-view-container';
+export const MODULA2_EXPLORER_VIEW_CONTAINER_TITLE_OPTIONS: ViewContainerTitleOptions = {
+  label: 'Modula-2 Project',
+  iconClass: codicon('folder-library'),
+  closeable: true,
+};
+
+/** Dedicated activity-bar container for the project view (with its icon). */
+@injectable()
+export class Modula2ExplorerContainerFactory {
+  static ID = MODULA2_EXPLORER_VIEW_CONTAINER_ID;
+  readonly id = Modula2ExplorerContainerFactory.ID;
+  @inject(ViewContainer.Factory) protected readonly viewContainerFactory: ViewContainer.Factory;
+  @inject(WidgetManager) protected readonly widgetManager: WidgetManager;
+  async createWidget(): Promise<Widget> {
+    const viewContainer = this.viewContainerFactory({
+      id: MODULA2_EXPLORER_VIEW_CONTAINER_ID,
+      progressLocationId: 'modula2-explorer',
+    });
+    viewContainer.setTitleOptions(MODULA2_EXPLORER_VIEW_CONTAINER_TITLE_OPTIONS);
+    const explorer = await this.widgetManager.getOrCreateWidget(MODULA2_EXPLORER_ID);
+    viewContainer.addWidget(explorer, {
+      order: 0, canHide: false, initiallyCollapsed: false, weight: 100,
+    });
+    return viewContainer;
+  }
+}
+
 export class Modula2ExplorerContribution extends AbstractViewContribution<Modula2ExplorerWidget> {
   constructor() {
     super({
+      viewContainerId: MODULA2_EXPLORER_VIEW_CONTAINER_ID,
       widgetId: MODULA2_EXPLORER_ID,
       widgetName: 'Modula-2 Project',
       defaultWidgetOptions: { area: 'left', rank: 300 },

+ 88 - 5
extensions/modula2-project/src/browser/modula2-project-frontend-module.ts

@@ -6,9 +6,11 @@ import {
 } 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 {
@@ -17,8 +19,8 @@ import {
   scaffoldFiles, validateProjectFile,
 } from './modula2-project';
 import {
-  MODULA2_EXPLORER_ID, Modula2ExplorerCommands, Modula2ExplorerContribution,
-  Modula2ExplorerMenus, createModula2ExplorerWidget,
+  MODULA2_EXPLORER_ID, Modula2ExplorerCommands, Modula2ExplorerContainerFactory,
+  Modula2ExplorerContribution, Modula2ExplorerMenus, createModula2ExplorerWidget,
 } from './modula2-explorer-widget';
 
 const CATEGORY = 'Modula-2';
@@ -27,6 +29,9 @@ 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 }),
 };
 
@@ -37,11 +42,16 @@ class ProjectCommands implements CommandContribution {
   @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() });
   }
 
@@ -93,12 +103,13 @@ class ProjectCommands implements CommandContribution {
     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: '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 === 'both';
-      makeMod = true;
+      makeDef = kind.value !== 'mod';
+      makeMod = kind.value !== 'def';
     }
     const choices = await this.sourceDirChoices(root);
     let dir = choices[0];
@@ -180,6 +191,68 @@ class ProjectCommands implements CommandContribution {
     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<void> {
+    await this.commands.executeCommand(WorkspaceCommands.OPEN_FOLDER.id);
+  }
+
+  /** Switch to a recently opened project (delegates to the recent picker). */
+  protected async switchProject(): Promise<void> {
+    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<void> {
+    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<void> {
+    await this.commands.executeCommand(WorkspaceCommands.CLOSE.id);
+  }
+
   protected async validateProject(): Promise<void> {
     const root = await this.root();
     if (!root) return;
@@ -230,6 +303,14 @@ class ProjectMenus implements MenuContribution {
     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' });
   }
 }
 
@@ -242,6 +323,8 @@ export default new ContainerModule(bind => {
     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();
 });