Kaynağa Gözat

Project explorer: Modula-2 Project side view

- native TreeWidget in the left panel (auto-opens on first run):
  project root from modula2.json with Sources/Definitions/Project
  groups, main-file marker and click-to-open
- live refresh on file/workspace changes plus a refresh command;
  works with and without modula2.json, depth-capped scan skipping
  dot-folders and node_modules
Eric Streit 1 hafta önce
ebeveyn
işleme
955d283ca4

+ 2 - 0
README.md

@@ -22,6 +22,8 @@ A starting point for a dedicated GNU Modula-2 IDE built on Eclipse Theia.
   evaluate/hover (debug type `modula2`, program defaults to `bin/<name>`)
 - Project wizards: New Module/Definition Module/Project plus Validate Project,
   with a `modula2.json` JSON schema for editor completions and validation
+- Modula-2 Project side view: Sources/Definitions/Project groups, main-file
+  marker, click-to-open, live refresh
 - Initial Modula-2 commands/menu
 - Example/grammar placeholders
 

+ 2 - 0
SOURCE-TREE.txt

@@ -24,6 +24,8 @@ extensions/modula2-project/README.md
 extensions/modula2-project/package.json
 extensions/modula2-project/src/browser/modula2-project-frontend-module.ts
 extensions/modula2-project/src/browser/modula2-project.ts
+extensions/modula2-project/src/browser/modula2-explorer-model.ts
+extensions/modula2-project/src/browser/modula2-explorer-widget.ts
 extensions/modula2-project/tsconfig.json
 extensions/modula2-workbench/package.json
 extensions/modula2-workbench/src/browser/modula2-workbench-frontend-module.ts

+ 24 - 6
extensions/modula2-project/package.json

@@ -2,15 +2,33 @@
   "name": "modula2-project",
   "displayName": "Modula-2 Project Support",
   "version": "0.1.0",
-  "keywords": ["theia-extension"],
-  "files": ["lib", "src"],
+  "keywords": [
+    "theia-extension"
+  ],
+  "files": [
+    "lib",
+    "src"
+  ],
   "dependencies": {
     "@theia/core": "1.75.0",
     "@theia/editor": "1.75.0",
     "@theia/filesystem": "1.75.0",
-    "@theia/workspace": "1.75.0"
+    "@theia/workspace": "1.75.0",
+    "react": "^19.0.0"
   },
-  "devDependencies": {"rimraf": "^6.0.1", "typescript": "^5.9.2"},
-  "scripts": {"clean": "rimraf lib", "build": "tsc -p tsconfig.json", "watch": "tsc -p tsconfig.json --watch", "prepare": "yarn clean && yarn build"},
-  "theiaExtensions": [{"frontend": "lib/browser/modula2-project-frontend-module"}]
+  "devDependencies": {
+    "rimraf": "^6.0.1",
+    "typescript": "^5.9.2"
+  },
+  "scripts": {
+    "clean": "rimraf lib",
+    "build": "tsc -p tsconfig.json",
+    "watch": "tsc -p tsconfig.json --watch",
+    "prepare": "yarn clean && yarn build"
+  },
+  "theiaExtensions": [
+    {
+      "frontend": "lib/browser/modula2-project-frontend-module"
+    }
+  ]
 }

+ 102 - 0
extensions/modula2-project/src/browser/modula2-explorer-model.ts

@@ -0,0 +1,102 @@
+/** Pure project-explorer model for the Modula-2 project view (testable headlessly).
+ *
+ *  Groups workspace files into Sources (`.mod`), Definitions (`.def`) and
+ *  Project files (`modula2.json`, built output). Filesystem access and
+ *  Theia tree widgets live in the widget module; everything here is plain
+ *  data over URI strings.
+ */
+
+export type ExplorerGroupId = 'sources' | 'definitions' | 'project';
+
+export interface ExplorerFileInput {
+  /** File URI string. */
+  uri: string;
+  /** Path relative to the workspace root, e.g. `src/Main.mod`. */
+  relative: string;
+  /** True when the path is the project's built output (no extension). */
+  output?: boolean;
+}
+
+export interface ExplorerEntry {
+  uri: string;
+  /** Display name (file name, without directories). */
+  name: string;
+  /** Relative path, shown as detail/tooltip. */
+  relative: string;
+  group: ExplorerGroupId;
+  /** True for the `main` file from `modula2.json`. */
+  main: boolean;
+  /** False for the built binary (listed, not opened). */
+  openable: boolean;
+}
+
+export interface ExplorerGroup {
+  id: ExplorerGroupId;
+  label: string;
+  files: ExplorerEntry[];
+}
+
+export interface ExplorerProject {
+  /** Display name: `modula2.json` name, else the folder name. */
+  title: string;
+  tooltip: string;
+  /** True when no `modula2.json` was found (loose folder mode). */
+  loose: boolean;
+}
+
+function baseName(p: string): string {
+  const slash = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
+  return slash >= 0 ? p.slice(slash + 1) : p;
+}
+
+function samePath(a: string, b: string): boolean {
+  const norm = (p: string): string => p.replace(/\\/g, '/').replace(/^\.\//, '');
+  return norm(a) === norm(b);
+}
+
+/** Group discovered files; `main` is the `main` entry of `modula2.json`, if any. */
+export function groupExplorerFiles(
+  files: ExplorerFileInput[], mainRel?: string,
+): ExplorerGroup[] {
+  const groups: ExplorerGroup[] = [
+    { id: 'sources', label: 'Sources', files: [] },
+    { id: 'definitions', label: 'Definitions', files: [] },
+    { id: 'project', label: 'Project', files: [] },
+  ];
+  const byId = new Map<ExplorerGroupId, ExplorerGroup>(groups.map(g => [g.id, g]));
+  for (const f of files) {
+    const name = baseName(f.relative);
+    if (f.output) {
+      byId.get('project')!.files.push({
+        uri: f.uri, name, relative: f.relative,
+        group: 'project', main: false, openable: false,
+      });
+      continue;
+    }
+    if (name === 'modula2.json') {
+      byId.get('project')!.files.push({
+        uri: f.uri, name, relative: f.relative,
+        group: 'project', main: false, openable: true,
+      });
+      continue;
+    }
+    if (name.endsWith('.mod')) {
+      byId.get('sources')!.files.push({
+        uri: f.uri, name, relative: f.relative, group: 'sources',
+        main: !!mainRel && samePath(f.relative, mainRel), openable: true,
+      });
+      continue;
+    }
+    if (name.endsWith('.def')) {
+      byId.get('definitions')!.files.push({
+        uri: f.uri, name, relative: f.relative, group: 'definitions',
+        main: false, openable: true,
+      });
+    }
+  }
+  for (const g of groups) {
+    g.files.sort((a, b) =>
+      Number(b.main) - Number(a.main) || a.name.localeCompare(b.name));
+  }
+  return groups.filter(g => g.files.length > 0);
+}

+ 235 - 0
extensions/modula2-project/src/browser/modula2-explorer-widget.ts

@@ -0,0 +1,235 @@
+import * as React from 'react';
+import { inject, injectable, interfaces } from '@theia/core/shared/inversify';
+import {
+  AbstractViewContribution, codicon, CompositeTreeNode,
+  ContextMenuRenderer, createTreeContainer,
+  ExpandableTreeNode, NodeProps, SelectableTreeNode,
+  TreeModel, TreeNode, TreeProps, TreeWidget, WidgetFactory, WidgetManager,
+} from '@theia/core/lib/browser';
+import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common';
+import { FileService } from '@theia/filesystem/lib/browser/file-service';
+import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
+import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
+import URI from '@theia/core/lib/common/uri';
+import { Message } from '@lumino/messaging';
+import { groupExplorerFiles } from './modula2-explorer-model';
+
+export const MODULA2_EXPLORER_ID = 'modula2-explorer';
+
+export interface Modula2TreeNode extends CompositeTreeNode, SelectableTreeNode, ExpandableTreeNode {
+  kind: 'project' | 'group' | 'file';
+  uri?: string;
+  openable: boolean;
+}
+
+function folderNode(id: string, name: string, kind: 'project' | 'group'): Modula2TreeNode {
+  return {
+    id, name, kind, openable: false,
+    parent: undefined as unknown as CompositeTreeNode,
+    children: [],
+    expanded: true,
+    selected: false,
+  };
+}
+
+function fileNode(
+  uri: string, name: string, openable: boolean,
+): Modula2TreeNode {
+  return {
+    id: uri, name, kind: 'file', uri, openable,
+    parent: undefined as unknown as CompositeTreeNode,
+    children: [],
+    expanded: false,
+    selected: false,
+  };
+}
+
+@injectable()
+export class Modula2ExplorerWidget extends TreeWidget {
+  static readonly ID = MODULA2_EXPLORER_ID;
+
+  @inject(FileService) protected readonly files: FileService;
+  @inject(WorkspaceService) protected readonly workspace: WorkspaceService;
+  @inject(EditorManager) protected readonly editors: EditorManager;
+
+  protected refreshTimer: ReturnType<typeof setTimeout> | undefined;
+  protected attached = false;
+
+  constructor(
+    props: TreeProps,
+    model: TreeModel,
+    contextMenuRenderer: ContextMenuRenderer,
+  ) {
+    super(props, model, contextMenuRenderer);
+    this.id = Modula2ExplorerWidget.ID;
+    this.title.label = 'Modula-2 Project';
+    this.title.caption = 'Modula-2 project files';
+    this.title.closable = false;
+    this.toDispose.push(this.model.onSelectionChanged(nodes => this.openSelected(nodes)));
+    this.toDispose.push(this.files.onDidFilesChange(() => this.scheduleRefresh()));
+    this.toDispose.push(this.workspace.onWorkspaceChanged(() => this.refresh()));
+    this.toDispose.push(this.workspace.onWorkspaceLocationChanged(() => this.refresh()));
+    void this.refresh();
+  }
+
+  protected override onAfterAttach(msg: Message): void {
+    super.onAfterAttach(msg);
+    if (!this.attached) {
+      this.attached = true;
+      void this.refresh();
+    }
+  }
+
+  protected scheduleRefresh(): void {
+    if (this.refreshTimer) clearTimeout(this.refreshTimer);
+    this.refreshTimer = setTimeout(() => {
+      this.refreshTimer = undefined;
+      void this.refresh();
+    }, 300);
+  }
+
+  async refresh(): Promise<void> {
+    try {
+      const roots = await this.workspace.roots;
+      if (roots.length === 0) {
+        this.model.root = folderNode('m2root', 'No folder open', 'project');
+        return;
+      }
+      const root = roots[0].resource;
+      const project = await this.readProject(root);
+      const found = await this.collectFiles(root, root, 0, [], project.output);
+      const groups = groupExplorerFiles(found, project.main);
+      const rootNode = folderNode(
+        'm2root',
+        project.name ?? `${root.path.base} (no modula2.json)`,
+        'project',
+      );
+      for (const group of groups) {
+        const groupNode = folderNode(`m2group:${group.id}`, group.label, 'group');
+        const fileNodes = group.files.map(f => fileNode(
+          f.uri,
+          f.main ? `${f.name} (main)` : f.name,
+          f.openable,
+        ));
+        CompositeTreeNode.addChildren(groupNode, fileNodes);
+        CompositeTreeNode.addChild(rootNode, groupNode);
+      }
+      if (rootNode.children.length === 0) {
+        const hint = folderNode('m2empty', 'No Modula-2 files found', 'group');
+        hint.expanded = false;
+        CompositeTreeNode.addChild(rootNode, hint);
+      }
+      this.model.root = rootNode;
+    } catch (error) {
+      console.error('Modula-2 explorer refresh failed:', error);
+    }
+  }
+
+  protected async readProject(root: URI): Promise<{ name?: string; main?: string; output?: string }> {
+    try {
+      const raw = JSON.parse((await this.files.read(root.resolve('modula2.json'))).value) as unknown;
+      if (typeof raw !== 'object' || raw === null) return {};
+      const r = raw as { name?: unknown; main?: unknown; output?: unknown };
+      const out: { name?: string; main?: string; output?: string } = {};
+      if (typeof r.name === 'string' && r.name.length > 0) out.name = r.name;
+      if (typeof r.main === 'string' && r.main.length > 0) out.main = r.main;
+      const configured = (typeof r.output === 'string' && r.output.length > 0)
+        ? r.output : (out.name ? `bin/${out.name}` : undefined);
+      if (configured) {
+        try {
+          if (await this.files.exists(root.resolve(configured))) out.output = configured;
+        } catch { /* listed only when present */ }
+      }
+      return out;
+    } catch {
+      return {};
+    }
+  }
+
+  protected async collectFiles(
+    root: URI, dir: URI, depth: number,
+    out: Array<{ uri: string; relative: string; output?: boolean }>,
+    outputRel?: string,
+  ): Promise<Array<{ uri: string; relative: string; output?: boolean }>> {
+    let stat;
+    try {
+      stat = await this.files.resolve(dir);
+    } catch {
+      return out;
+    }
+    for (const child of stat.children ?? []) {
+      const name = child.resource.path.base;
+      if (child.isDirectory) {
+        if (name.startsWith('.') || name === 'node_modules') continue;
+        if (depth < 8) await this.collectFiles(root, child.resource, depth + 1, out, outputRel);
+        continue;
+      }
+      const rel = root.relative(child.resource)?.toString() ?? name;
+      if (name === 'modula2.json' || name.endsWith('.mod') || name.endsWith('.def')) {
+        out.push({ uri: child.resource.toString(), relative: rel });
+      } else if (outputRel && rel === outputRel) {
+        out.push({ uri: child.resource.toString(), relative: rel, output: true });
+      }
+    }
+    return out;
+  }
+
+  protected openSelected(nodes: readonly Readonly<SelectableTreeNode>[]): void {
+    const node = nodes[0] as Modula2TreeNode | undefined;
+    if (node && node.kind === 'file' && node.uri && node.openable) {
+      this.editors.open(new URI(node.uri), { mode: 'reveal' }).catch(() => undefined);
+    }
+  }
+
+  protected override renderIcon(node: TreeNode, props: NodeProps): React.ReactNode {
+    const n = node as Partial<Modula2TreeNode>;
+    if (n.kind === 'project') return React.createElement('div', { className: codicon('folder-library') });
+    if (n.kind === 'group') return React.createElement('div', { className: codicon('folder') });
+    if (n.kind === 'file') {
+      if ((n.uri ?? '').endsWith('modula2.json')) {
+        return React.createElement('div', { className: codicon('json') });
+      }
+      return React.createElement('div', { className: codicon('file-code') });
+    }
+    return super.renderIcon(node, props);
+  }
+}
+
+export class Modula2ExplorerContribution extends AbstractViewContribution<Modula2ExplorerWidget> {
+  constructor() {
+    super({
+      widgetId: MODULA2_EXPLORER_ID,
+      widgetName: 'Modula-2 Project',
+      defaultWidgetOptions: { area: 'left', rank: 300 },
+      toggleCommandId: 'modula2Explorer:toggle',
+    });
+  }
+
+  async initializeLayout(): Promise<void> {
+    await this.openView({ activate: false, reveal: true });
+  }
+}
+
+export const RefreshExplorerCommand: Command = Command.toLocalizedCommand({
+  id: 'modula2.refreshExplorer', label: 'Refresh Project Explorer', category: 'Modula-2',
+});
+
+@injectable()
+export class Modula2ExplorerCommands implements CommandContribution {
+  @inject(WidgetManager) protected readonly widgets: WidgetManager;
+
+  registerCommands(commands: CommandRegistry): void {
+    commands.registerCommand(RefreshExplorerCommand, {
+      execute: () => {
+        for (const widget of this.widgets.getWidgets(MODULA2_EXPLORER_ID)) {
+          void (widget as Modula2ExplorerWidget).refresh();
+        }
+      },
+    });
+  }
+}
+
+export function createModula2ExplorerWidget(parent: interfaces.Container): Modula2ExplorerWidget {
+  const child = createTreeContainer(parent, { widget: Modula2ExplorerWidget });
+  return child.get(Modula2ExplorerWidget);
+}

+ 11 - 0
extensions/modula2-project/src/browser/modula2-project-frontend-module.ts

@@ -4,6 +4,7 @@ import {
   Command, CommandContribution, CommandRegistry, MenuContribution,
   MenuModelRegistry, MessageService,
 } from '@theia/core/lib/common';
+import { WidgetFactory, bindViewContribution } from '@theia/core/lib/browser';
 import { QuickInputService } from '@theia/core/lib/common/quick-pick-service';
 import { BinaryBuffer } from '@theia/core/lib/common/buffer';
 import { FileService } from '@theia/filesystem/lib/browser/file-service';
@@ -15,6 +16,10 @@ import {
   isValidProjectName, moduleNameOf, renderDefModule, renderImplModule,
   scaffoldFiles, validateProjectFile,
 } from './modula2-project';
+import {
+  MODULA2_EXPLORER_ID, Modula2ExplorerCommands, Modula2ExplorerContribution,
+  createModula2ExplorerWidget,
+} from './modula2-explorer-widget';
 
 const CATEGORY = 'Modula-2';
 
@@ -231,4 +236,10 @@ class ProjectMenus implements MenuContribution {
 export default new ContainerModule(bind => {
   bind(CommandContribution).to(ProjectCommands).inSingletonScope();
   bind(MenuContribution).to(ProjectMenus).inSingletonScope();
+  bindViewContribution(bind, Modula2ExplorerContribution);
+  bind(WidgetFactory).toDynamicValue(context => ({
+    id: MODULA2_EXPLORER_ID,
+    createWidget: () => createModula2ExplorerWidget(context.container),
+  }));
+  bind(CommandContribution).to(Modula2ExplorerCommands).inSingletonScope();
 });