|
|
@@ -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);
|
|
|
+}
|