Quellcode durchsuchen

Pascal/Oberon project wizards: templates, validation, explorer (PRJ)

Eric Streit vor 2 Tagen
Ursprung
Commit
21a4f3b7e3

+ 3 - 1
apps/browser/package.json

@@ -29,7 +29,9 @@
     "pascal-workbench": "0.1.0",
     "oberon-compiler": "0.2.0",
     "oberon-debug": "0.1.0",
-    "oberon-workbench": "0.1.0"
+    "oberon-workbench": "0.1.0",
+    "pascal-project": "0.1.0",
+    "oberon-project": "0.1.0"
   },
   "devDependencies": {
     "@theia/cli": "1.75.0",

+ 3 - 1
apps/electron/package.json

@@ -35,7 +35,9 @@
     "pascal-workbench": "0.1.0",
     "oberon-compiler": "0.2.0",
     "oberon-debug": "0.1.0",
-    "oberon-workbench": "0.1.0"
+    "oberon-workbench": "0.1.0",
+    "pascal-project": "0.1.0",
+    "oberon-project": "0.1.0"
   },
   "devDependencies": {
     "@theia/cli": "1.75.0",

+ 3 - 0
extensions/oberon-project/README.md

@@ -0,0 +1,3 @@
+# oberon-project
+
+Reserved for `oberon.json` project support and project discovery.

+ 34 - 0
extensions/oberon-project/package.json

@@ -0,0 +1,34 @@
+{
+  "name": "oberon-project",
+  "displayName": "Oberon Project Support",
+  "version": "0.1.0",
+  "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",
+    "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/oberon-project-frontend-module"
+    }
+  ]
+}

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

@@ -0,0 +1,102 @@
+/** Pure project-explorer model for the Oberon project view (testable headlessly).
+ *
+ *  Groups workspace files into Sources (`.Mod`/`.mod`/`.om`),
+ *  Definitions (`.def`/`.od`) and Project files (`oberon.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 `oberon.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: `oberon.json` name, else the folder name. */
+  title: string;
+  tooltip: string;
+  /** True when no `oberon.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 `oberon.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 === 'oberon.json') {
+      byId.get('project')!.files.push({
+        uri: f.uri, name, relative: f.relative,
+        group: 'project', main: false, openable: true,
+      });
+      continue;
+    }
+    if (name.endsWith('.Mod') || name.endsWith('.mod') || name.endsWith('.om')) {
+      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') || name.endsWith('.od')) {
+      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);
+}

+ 396 - 0
extensions/oberon-project/src/browser/oberon-explorer-widget.ts

@@ -0,0 +1,396 @@
+import * as React from 'react';
+import { inject, injectable, interfaces, postConstruct } from '@theia/core/shared/inversify';
+import {
+  AbstractViewContribution, codicon, CompositeTreeNode,
+  ContextMenuRenderer, createTreeContainer,
+  ExpandableTreeNode, NodeProps, SelectableTreeNode,
+  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';
+import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
+import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
+import { QuickInputService } from '@theia/core/lib/common/quick-pick-service';
+import { BinaryBuffer } from '@theia/core/lib/common/buffer';
+import URI from '@theia/core/lib/common/uri';
+import { Message } from '@lumino/messaging';
+import { groupExplorerFiles } from './oberon-explorer-model';
+import { isValidModuleName, renderModule } from './oberon-project';
+
+export const OBERON_EXPLORER_ID = 'oberon-explorer';
+
+/** Context menu path rendered by the explorer tree on right-click. */
+export const OBERON_EXPLORER_CONTEXT_MENU = ['oberon-explorer-context-menu'];
+
+export interface OberonTreeNode extends CompositeTreeNode, SelectableTreeNode, ExpandableTreeNode {
+  kind: 'project' | 'group' | 'file';
+  uri?: string;
+  openable: boolean;
+}
+
+function folderNode(id: string, name: string, kind: 'project' | 'group'): OberonTreeNode {
+  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,
+): OberonTreeNode {
+  return {
+    id: uri, name, kind: 'file', uri, openable,
+    parent: undefined as unknown as CompositeTreeNode,
+    children: [],
+    expanded: false,
+    selected: false,
+  };
+}
+
+@injectable()
+export class OberonExplorerWidget extends TreeWidget {
+  static readonly ID = OBERON_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 = OberonExplorerWidget.ID;
+    this.title.label = 'Oberon Project';
+    this.title.caption = 'Oberon 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()
+  protected initialize(): void {
+    // NOTE: inversify only invokes the most-derived @postConstruct, so the
+    // base TreeWidget.init() (row-update wiring) must be called explicitly.
+    super.init();
+    // Injections are complete here (never touch them in the constructor).
+    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()));
+    // 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();
+  }
+
+  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('oberonroot', '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(
+        'oberonroot',
+        project.name ?? `${root.path.base} (no oberon.json)`,
+        'project',
+      );
+      for (const group of groups) {
+        const groupNode = folderNode(`oberongroup:${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('oberonempty', 'No Oberon files found', 'group');
+        hint.expanded = false;
+        CompositeTreeNode.addChild(rootNode, hint);
+      }
+      this.model.root = rootNode;
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      console.error('Oberon explorer refresh failed:', message);
+      const errNode = folderNode('oberonerror', 'Explorer error', 'group');
+      const detail = fileNode('oberonerror:detail', message.slice(0, 120), false);
+      CompositeTreeNode.addChild(errNode, detail);
+      this.model.root = errNode;
+    }
+  }
+
+  protected async readProject(root: URI): Promise<{ name?: string; main?: string; output?: string }> {
+    try {
+      const raw = JSON.parse((await this.files.read(root.resolve('oberon.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 === 'oberon.json' || name.endsWith('.Mod') || name.endsWith('.mod') || name.endsWith('.om') || name.endsWith('.def') || name.endsWith('.od')) {
+        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 OberonTreeNode | 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<OberonTreeNode>;
+    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('oberon.json')) {
+        return React.createElement('div', { className: codicon('json') });
+      }
+      return React.createElement('div', { className: codicon('file-code') });
+    }
+    return super.renderIcon(node, props);
+  }
+}
+
+export const OBERON_EXPLORER_VIEW_CONTAINER_ID = 'oberon-explorer-view-container';
+export const OBERON_EXPLORER_VIEW_CONTAINER_TITLE_OPTIONS: ViewContainerTitleOptions = {
+  label: 'Oberon Project',
+  iconClass: codicon('folder-library'),
+  closeable: true,
+};
+
+/** Dedicated activity-bar container for the project view (with its icon). */
+@injectable()
+export class OberonExplorerContainerFactory {
+  static ID = OBERON_EXPLORER_VIEW_CONTAINER_ID;
+  readonly id = OberonExplorerContainerFactory.ID;
+  @inject(ViewContainer.Factory) protected readonly viewContainerFactory: ViewContainer.Factory;
+  @inject(WidgetManager) protected readonly widgetManager: WidgetManager;
+  async createWidget(): Promise<Widget> {
+    const viewContainer = this.viewContainerFactory({
+      id: OBERON_EXPLORER_VIEW_CONTAINER_ID,
+      progressLocationId: 'oberon-explorer',
+    });
+    viewContainer.setTitleOptions(OBERON_EXPLORER_VIEW_CONTAINER_TITLE_OPTIONS);
+    const explorer = await this.widgetManager.getOrCreateWidget(OBERON_EXPLORER_ID);
+    viewContainer.addWidget(explorer, {
+      order: 0, canHide: false, initiallyCollapsed: false, weight: 100,
+    });
+    return viewContainer;
+  }
+}
+
+export class OberonExplorerContribution extends AbstractViewContribution<OberonExplorerWidget> {
+  constructor() {
+    super({
+      viewContainerId: OBERON_EXPLORER_VIEW_CONTAINER_ID,
+      widgetId: OBERON_EXPLORER_ID,
+      widgetName: 'Oberon Project',
+      defaultWidgetOptions: { area: 'left', rank: 300 },
+      toggleCommandId: 'oberonExplorer:toggle',
+    });
+  }
+
+  async initializeLayout(): Promise<void> {
+    await this.openView({ activate: false, reveal: true });
+  }
+}
+
+export const RefreshExplorerCommand: Command = Command.toLocalizedCommand({
+  id: 'oberon.refreshExplorer', label: 'Refresh Project Explorer', category: 'Oberon',
+});
+
+export const NewModuleHereCommand: Command = Command.toLocalizedCommand({
+  id: 'oberon.newModuleHere', label: 'New Module Here...', category: 'Oberon',
+});
+
+export const NewFolderHereCommand: Command = Command.toLocalizedCommand({
+  id: 'oberon.newFolderHere', label: 'New Folder Here...', category: 'Oberon',
+});
+
+@injectable()
+export class OberonExplorerCommands implements CommandContribution {
+  @inject(WidgetManager) protected readonly widgets: WidgetManager;
+  @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;
+
+  registerCommands(commands: CommandRegistry): void {
+    commands.registerCommand(RefreshExplorerCommand, {
+      execute: () => {
+        for (const widget of this.widgets.getWidgets(OBERON_EXPLORER_ID)) {
+          void (widget as OberonExplorerWidget).refresh();
+        }
+      },
+    });
+    commands.registerCommand(NewModuleHereCommand, { execute: () => this.newModuleHere() });
+    commands.registerCommand(NewFolderHereCommand, { execute: () => this.newFolderHere() });
+  }
+
+  /** Folder of the selected explorer file, else the workspace root. */
+  protected async selectedDir(): Promise<URI | undefined> {
+    for (const widget of this.widgets.getWidgets(OBERON_EXPLORER_ID)) {
+      const view = widget as OberonExplorerWidget;
+      const node = view.model.selectedNodes[0] as OberonTreeNode | undefined;
+      if (node?.kind === 'file' && node.uri) {
+        return new URI(node.uri).parent;
+      }
+    }
+    const roots = await this.workspace.roots;
+    return roots[0]?.resource;
+  }
+
+  protected async newModuleHere(): Promise<void> {
+    const dir = await this.selectedDir();
+    if (!dir) {
+      this.messages.warn('Oberon: open a folder or workspace first.');
+      return;
+    }
+    const raw = await this.quickInput.input({
+      prompt: 'Module name (an Oberon identifier, e.g. MyModule)',
+      placeHolder: 'MyModule',
+      validateInput: async value => isValidModuleName(value.trim()) ?? null,
+    });
+    if (raw === undefined) return;
+    const name = raw.trim();
+    const file = `${name}.Mod`;
+    const uri = dir.resolve(file);
+    try {
+      if (await this.files.exists(uri)) {
+        this.messages.warn(`Oberon: ${file} already exists, skipped.`);
+        return;
+      }
+      await this.files.writeFile(uri, BinaryBuffer.fromString(renderModule(name)));
+    } catch (error) {
+      this.messages.error(`Oberon: cannot write ${file}: ${error instanceof Error ? error.message : String(error)}`);
+      return;
+    }
+    this.messages.info(`Oberon: created ${file}.`);
+    try {
+      await this.editors.open(uri, { mode: 'activate' });
+    } catch { /* created is enough */ }
+  }
+
+  protected async newFolderHere(): Promise<void> {
+    const dir = await this.selectedDir();
+    if (!dir) {
+      this.messages.warn('Oberon: open a folder or workspace first.');
+      return;
+    }
+    const raw = await this.quickInput.input({
+      prompt: 'Folder name (e.g. sources)',
+      placeHolder: 'sources',
+      validateInput: async value => {
+        const name = value.trim();
+        if (name.length === 0) return 'Enter a folder name.';
+        if (name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
+          return 'A single folder name, without path separators.';
+        }
+        return null;
+      },
+    });
+    if (raw === undefined) return;
+    const uri = dir.resolve(raw.trim());
+    try {
+      if (await this.files.exists(uri)) {
+        this.messages.warn('Oberon: that folder already exists.');
+        return;
+      }
+      await this.files.createFolder(uri);
+      this.messages.info(`Oberon: created folder ${raw.trim()}.`);
+    } catch (error) {
+      this.messages.error(`Oberon: cannot create folder: ${error instanceof Error ? error.message : String(error)}`);
+    }
+  }
+}
+
+export class OberonExplorerMenus implements MenuContribution {
+  registerMenus(menus: MenuModelRegistry): void {
+    const path = OBERON_EXPLORER_CONTEXT_MENU;
+    menus.registerMenuAction(path, { commandId: NewModuleHereCommand.id, order: '10' });
+    menus.registerMenuAction(path, { commandId: NewFolderHereCommand.id, order: '11' });
+    menus.registerMenuAction(path, { commandId: 'oberon.build', order: '20' });
+    menus.registerMenuAction(path, { commandId: RefreshExplorerCommand.id, order: '30' });
+  }
+}
+
+export function createOberonExplorerWidget(parent: interfaces.Container): OberonExplorerWidget {
+  const child = createTreeContainer(parent, {
+    widget: OberonExplorerWidget,
+    props: { contextMenuPath: OBERON_EXPLORER_CONTEXT_MENU },
+  });
+  return child.get(OberonExplorerWidget);
+}

+ 333 - 0
extensions/oberon-project/src/browser/oberon-project-frontend-module.ts

@@ -0,0 +1,333 @@
+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, renderModule,
+  scaffoldFiles, validateProjectFile,
+} from './oberon-project';
+import {
+  OBERON_EXPLORER_ID, OberonExplorerCommands, OberonExplorerContainerFactory,
+  OberonExplorerContribution, OberonExplorerMenus, createOberonExplorerWidget,
+} from './oberon-explorer-widget';
+
+const CATEGORY = 'Oberon';
+
+const Commands = {
+  newModule: Command.toLocalizedCommand({ id: 'oberon.newModule', label: 'New Module...', category: CATEGORY }),
+  newProject: Command.toLocalizedCommand({ id: 'oberon.newProject', label: 'New Oberon Project...', category: CATEGORY }),
+  openProject: Command.toLocalizedCommand({ id: 'oberon.openProject', label: 'Open Project...', category: CATEGORY }),
+  switchProject: Command.toLocalizedCommand({ id: 'oberon.switchProject', label: 'Switch Project...', category: CATEGORY }),
+  closeProject: Command.toLocalizedCommand({ id: 'oberon.closeProject', label: 'Close Project', category: CATEGORY }),
+  validate: Command.toLocalizedCommand({ id: 'oberon.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() });
+    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<URI | undefined> {
+    const roots = await this.workspace.roots;
+    if (roots.length === 0) {
+      this.messages.warn('Oberon: 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<string[]> {
+    const declared: string[] = [];
+    try {
+      const content = await this.files.read(root.resolve('oberon.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<string | undefined> {
+    return this.quickInput.input({
+      prompt: 'Module name (an Oberon identifier, e.g. MyModule)',
+      placeHolder: 'MyModule',
+      validateInput: async value => isValidModuleName(value.trim()) ?? null,
+    });
+  }
+
+  protected async newModule(): Promise<void> {
+    const root = await this.root();
+    if (!root) return;
+    const raw = await this.askModuleName();
+    if (raw === undefined) return;
+    const name = raw.trim();
+    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 file = `${name}.Mod`;
+    const uri = dir === '.' ? root.resolve(file) : root.resolve(`${dir}/${file}`);
+    try {
+      if (await this.files.exists(uri)) {
+        this.messages.warn(`Oberon: ${file} already exists, skipped.`);
+        return;
+      }
+      await this.files.writeFile(uri, BinaryBuffer.fromString(renderModule(name)));
+    } catch (error) {
+      this.messages.error(`Oberon: cannot write ${file}: ${error instanceof Error ? error.message : String(error)}`);
+      return;
+    }
+    this.messages.info(`Oberon: created ${file}.`);
+    const first = dir === '.' ? root.resolve(file) : root.resolve(`${dir}/${file}`);
+    try {
+      await this.editors.open(first, { mode: 'activate' });
+    } catch { /* created is enough */ }
+  }
+
+  protected async newProject(): Promise<void> {
+    const root = await this.root();
+    if (!root) return;
+    const rawName = await this.quickInput.input({
+      prompt: 'Project name (used for the folder and oberon.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;
+    // A new project always lives in its own folder: a sibling of the open
+    // workspace, or the workspace itself when that is still empty.
+    let target = root;
+    let sibling = false;
+    try {
+      const stat = await this.files.resolve(root);
+      if ((stat.children ?? []).length > 0) {
+        target = root.parent.resolve(name);
+        sibling = true;
+      }
+    } catch {
+      target = root.parent.resolve(name);
+      sibling = true;
+    }
+    if (sibling) {
+      try {
+        if (await this.files.exists(target)) {
+          const openIt = await this.quickInput.showQuickPick(
+            [{ label: `Switch to "${name}"`, value: 'switch' },
+             { label: 'Stay here', value: 'stay' }],
+            { placeholder: `"${name}" already exists next to the open folder.` },
+          );
+          if (openIt?.value === 'switch') this.workspace.open(target);
+          return;
+        }
+      } catch { /* fall through and try creating */ }
+    }
+    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(`Oberon: ${f.path} already exists, skipped.`);
+          continue;
+        }
+        await this.files.writeFile(uri, BinaryBuffer.fromString(f.content));
+      }
+    } catch (error) {
+      this.messages.error(`Oberon: 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(`Oberon: created ${template.label} "${name}" in ${where}. Open oberon.json to review, then Oberon: Build.`);
+    if (sibling) {
+      const openIt = await this.quickInput.showQuickPick(
+        [{ label: `Switch to "${name}"`, value: 'switch' },
+         { label: 'Stay here', value: 'stay' }],
+        { placeholder: 'New project created.' },
+      );
+      if (openIt?.value === 'switch') this.workspace.open(target);
+    }
+  }
+
+  /** 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 Oberon 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 }[]>('oberon.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 Oberon 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('oberon.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('oberon.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;
+    const uri = root.resolve('oberon.json');
+    let text: string;
+    try {
+      text = (await this.files.read(uri)).value;
+    } catch {
+      this.messages.warn('Oberon: no oberon.json in the open folder. Create one with Oberon: New Oberon Project.');
+      return;
+    }
+    const problems = validateProjectFile(text);
+    if (problems.length > 0) {
+      this.messages.error(`Oberon: invalid project.\n${problems.join('\n')}`);
+      return;
+    }
+    // Existence checks against the workspace.
+    let raw: { name: string; main: string; sourceDirectories?: string[] };
+    try {
+      raw = JSON.parse(text);
+    } catch {
+      this.messages.error('Oberon: invalid project.\noberon.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'] 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(`Oberon: project "${raw.name}" has problems.\n${missing.join('\n')}`);
+      return;
+    }
+    this.messages.info(`Oberon: project "${raw.name}" is valid (main: ${raw.main}).`);
+  }
+}
+
+class ProjectMenus implements MenuContribution {
+  registerMenus(menus: MenuModelRegistry): void {
+    const submenu = ['menubar', 'oberon'];
+    menus.registerMenuAction(submenu, { commandId: Commands.newModule.id, order: '20' });
+    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.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, OberonExplorerContribution);
+  bind(FrontendApplicationContribution).toService(OberonExplorerContribution);
+  bind(WidgetFactory).toDynamicValue(context => ({
+    id: OBERON_EXPLORER_ID,
+    createWidget: () => createOberonExplorerWidget(context.container),
+  }));
+  bind(OberonExplorerContainerFactory).toSelf().inSingletonScope();
+  bind(WidgetFactory).toService(OberonExplorerContainerFactory);
+  bind(CommandContribution).to(OberonExplorerCommands).inSingletonScope();
+  bind(MenuContribution).to(OberonExplorerMenus).inSingletonScope();
+});

+ 163 - 0
extensions/oberon-project/src/browser/oberon-project.ts

@@ -0,0 +1,163 @@
+/** Pure project logic for the Oberon project extension (testable headlessly).
+ *
+ *  Covers module/project name validation, file templates, structural
+ *  `oberon.json` validation and candidate source directories. All
+ *  filesystem and UI interaction lives in the frontend module.
+ */
+
+export type ProjectTemplate = 'console' | 'empty' | 'library';
+
+export const TEMPLATES: Array<{ id: ProjectTemplate; label: string; description: string }> = [
+  { id: 'console', label: 'Console application', description: 'Hello-world module with oberon.json' },
+  { id: 'empty', label: 'Empty project', description: 'oberon.json and an empty src directory' },
+  { id: 'library', label: 'Library', description: 'Exporting module skeleton' },
+];
+
+/** Error message for an invalid module name, or undefined when valid. */
+export function isValidModuleName(name: string): string | undefined {
+  if (!name) return 'Module name must not be empty.';
+  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
+    return `"${name}" is not a valid Oberon identifier (letters, digits, underscore; must not start with a digit).`;
+  }
+  return undefined;
+}
+
+/** Error message for an invalid project/folder name, or undefined when valid. */
+export function isValidProjectName(name: string): string | undefined {
+  if (!name) return 'Project name must not be empty.';
+  if (/[/\\]/.test(name) || name === '.' || name === '..') {
+    return `"${name}" must be a plain folder name without path separators.`;
+  }
+  return undefined;
+}
+
+/** Derive a module identifier from an arbitrary project name (`my-lib` -> `MyLib`). */
+export function moduleNameOf(projectName: string): string {
+  const parts = projectName.split(/[^A-Za-z0-9]+/).filter(p => p.length > 0);
+  const camel = parts.map(p => p[0].toUpperCase() + p.slice(1)).join('');
+  const clean = camel.replace(/^[^A-Za-z_]+/, '');
+  return clean || 'Main';
+}
+
+export function renderModule(name: string): string {
+  return `MODULE ${name};
+
+IMPORT Out;
+
+BEGIN
+  Out.String("Hello, Oberon!"); Out.Ln
+END ${name}.
+`;
+}
+
+export function renderLibraryModule(name: string): string {
+  return `MODULE ${name};
+
+(* Exported API goes here. Mark exported names with "*". *)
+
+PROCEDURE Hello*;
+BEGIN
+END Hello;
+
+END ${name}.
+`;
+}
+
+export function renderProjectJson(name: string, main: string): string {
+  return JSON.stringify({
+    name,
+    compiler: { type: 'voc', path: 'voc', options: [] },
+    sourceDirectories: ['src'],
+    main,
+  }, undefined, 2) + '\n';
+}
+
+export function renderReadme(name: string): string {
+  return `# ${name}\n\nA voc Oberon project. Build with the Oberon IDE\n(\`Oberon: Build\`) or from the terminal:\n\n\`\`\`bash\nvoc src/Main.Mod\n\`\`\`\n`;
+}
+
+/** Files (relative to the project root) produced for a template. */
+export function scaffoldFiles(projectName: string, template: ProjectTemplate): Array<{ path: string; content: string }> {
+  const mod = moduleNameOf(projectName);
+  switch (template) {
+    case 'console':
+      return [
+        { path: 'oberon.json', content: renderProjectJson(projectName, 'src/Main.Mod') },
+        { path: 'src/Main.Mod', content: renderModule(mod === 'Main' ? 'Main' : mod) },
+        { path: 'README.md', content: renderReadme(projectName) },
+      ];
+    case 'empty':
+      return [
+        { path: 'oberon.json', content: renderProjectJson(projectName, 'src/Main.Mod') },
+        { path: 'README.md', content: renderReadme(projectName) },
+      ];
+    case 'library':
+      return [
+        { path: 'oberon.json', content: renderProjectJson(projectName, `src/${mod}.Mod`) },
+        { path: `src/${mod}.Mod`, content: renderLibraryModule(mod) },
+        { path: 'README.md', content: renderReadme(projectName) },
+      ];
+  }
+}
+
+/** Structural problems in an `oberon.json` document (no filesystem access). */
+export function validateProjectFile(jsonText: string): string[] {
+  let raw: {
+    name?: unknown; main?: unknown; output?: unknown;
+    compiler?: { path?: unknown; options?: unknown };
+    sourceDirectories?: unknown;
+  };
+  try {
+    raw = JSON.parse(jsonText);
+  } catch {
+    return ['oberon.json: invalid JSON.'];
+  }
+  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
+    return ['oberon.json: top level must be an object.'];
+  }
+  const problems: string[] = [];
+  if (typeof raw.name !== 'string' || raw.name.length === 0) {
+    problems.push('oberon.json: missing "name" (project name).');
+  }
+  if (typeof raw.main !== 'string' || raw.main.length === 0) {
+    problems.push('oberon.json: missing "main" (main file, e.g. "src/Main.Mod").');
+  } else if (!/\.(Mod|mod|om)$/.test(raw.main)) {
+    problems.push(`oberon.json: "main" should point at a .Mod file (got "${raw.main}").`);
+  }
+  if (raw.output !== undefined && (typeof raw.output !== 'string' || raw.output.length === 0)) {
+    problems.push('oberon.json: "output" must be a non-empty path when present.');
+  }
+  const compiler = (raw as { compiler?: unknown }).compiler;
+  if (compiler !== undefined) {
+    if (typeof compiler !== 'object' || compiler === null) {
+      problems.push('oberon.json: "compiler" must be an object.');
+    } else {
+      const c = compiler as { path?: unknown; options?: unknown };
+      if (c.path !== undefined && (typeof c.path !== 'string' || c.path.length === 0)) {
+        problems.push('oberon.json: "compiler.path" must be a non-empty executable path.');
+      }
+      if (c.options !== undefined &&
+          (!Array.isArray(c.options) || c.options.some(o => typeof o !== 'string'))) {
+        problems.push('oberon.json: "compiler.options" must be an array of strings.');
+      }
+    }
+  }
+  const v = (raw as Record<string, unknown>).sourceDirectories;
+  if (v !== undefined && (!Array.isArray(v) || v.some(d => typeof d !== 'string'))) {
+    problems.push('oberon.json: "sourceDirectories" must be an array of strings.');
+  }
+  return problems;
+}
+
+/** Declared source directories from an `oberon.json` document (tolerant; empty when absent). */
+export function declaredSourceDirs(jsonText: string): string[] {
+  try {
+    const raw = JSON.parse(jsonText) as { sourceDirectories?: unknown };
+    if (Array.isArray(raw.sourceDirectories)) {
+      return raw.sourceDirectories.filter((d): d is string => typeof d === 'string' && d.length > 0);
+    }
+  } catch {
+    // Fall through: no declared directories.
+  }
+  return [];
+}

+ 3 - 0
extensions/pascal-project/README.md

@@ -0,0 +1,3 @@
+# pascal-project
+
+Reserved for `pascal.json` project support and project discovery.

+ 34 - 0
extensions/pascal-project/package.json

@@ -0,0 +1,34 @@
+{
+  "name": "pascal-project",
+  "displayName": "Pascal Project Support",
+  "version": "0.1.0",
+  "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",
+    "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/pascal-project-frontend-module"
+    }
+  ]
+}

+ 95 - 0
extensions/pascal-project/src/browser/pascal-explorer-model.ts

@@ -0,0 +1,95 @@
+/** Pure project-explorer model for the Pascal project view (testable headlessly).
+ *
+ *  Groups workspace files into Sources (`.pas`/`.pp`/`.p`) and
+ *  Project files (`pascal.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' | 'project';
+
+export interface ExplorerFileInput {
+  /** File URI string. */
+  uri: string;
+  /** Path relative to the workspace root, e.g. `src/Main.pas`. */
+  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 `pascal.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: `pascal.json` name, else the folder name. */
+  title: string;
+  tooltip: string;
+  /** True when no `pascal.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 `pascal.json`, if any. */
+export function groupExplorerFiles(
+  files: ExplorerFileInput[], mainRel?: string,
+): ExplorerGroup[] {
+  const groups: ExplorerGroup[] = [
+    { id: 'sources', label: 'Sources', 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 === 'pascal.json') {
+      byId.get('project')!.files.push({
+        uri: f.uri, name, relative: f.relative,
+        group: 'project', main: false, openable: true,
+      });
+      continue;
+    }
+    if (name.endsWith('.pas') || name.endsWith('.pp') || name.endsWith('.p')) {
+      byId.get('sources')!.files.push({
+        uri: f.uri, name, relative: f.relative, group: 'sources',
+        main: !!mainRel && samePath(f.relative, mainRel), openable: true,
+      });
+      continue;
+    }
+  }
+  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);
+}

+ 396 - 0
extensions/pascal-project/src/browser/pascal-explorer-widget.ts

@@ -0,0 +1,396 @@
+import * as React from 'react';
+import { inject, injectable, interfaces, postConstruct } from '@theia/core/shared/inversify';
+import {
+  AbstractViewContribution, codicon, CompositeTreeNode,
+  ContextMenuRenderer, createTreeContainer,
+  ExpandableTreeNode, NodeProps, SelectableTreeNode,
+  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';
+import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
+import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
+import { QuickInputService } from '@theia/core/lib/common/quick-pick-service';
+import { BinaryBuffer } from '@theia/core/lib/common/buffer';
+import URI from '@theia/core/lib/common/uri';
+import { Message } from '@lumino/messaging';
+import { groupExplorerFiles } from './pascal-explorer-model';
+import { isValidModuleName, renderUnit } from './pascal-project';
+
+export const PASCAL_EXPLORER_ID = 'pascal-explorer';
+
+/** Context menu path rendered by the explorer tree on right-click. */
+export const PASCAL_EXPLORER_CONTEXT_MENU = ['pascal-explorer-context-menu'];
+
+export interface PascalTreeNode extends CompositeTreeNode, SelectableTreeNode, ExpandableTreeNode {
+  kind: 'project' | 'group' | 'file';
+  uri?: string;
+  openable: boolean;
+}
+
+function folderNode(id: string, name: string, kind: 'project' | 'group'): PascalTreeNode {
+  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,
+): PascalTreeNode {
+  return {
+    id: uri, name, kind: 'file', uri, openable,
+    parent: undefined as unknown as CompositeTreeNode,
+    children: [],
+    expanded: false,
+    selected: false,
+  };
+}
+
+@injectable()
+export class PascalExplorerWidget extends TreeWidget {
+  static readonly ID = PASCAL_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 = PascalExplorerWidget.ID;
+    this.title.label = 'Pascal Project';
+    this.title.caption = 'Pascal 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()
+  protected initialize(): void {
+    // NOTE: inversify only invokes the most-derived @postConstruct, so the
+    // base TreeWidget.init() (row-update wiring) must be called explicitly.
+    super.init();
+    // Injections are complete here (never touch them in the constructor).
+    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()));
+    // 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();
+  }
+
+  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('pascalroot', '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(
+        'pascalroot',
+        project.name ?? `${root.path.base} (no pascal.json)`,
+        'project',
+      );
+      for (const group of groups) {
+        const groupNode = folderNode(`pascalgroup:${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('pascalempty', 'No Pascal files found', 'group');
+        hint.expanded = false;
+        CompositeTreeNode.addChild(rootNode, hint);
+      }
+      this.model.root = rootNode;
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      console.error('Pascal explorer refresh failed:', message);
+      const errNode = folderNode('pascalerror', 'Explorer error', 'group');
+      const detail = fileNode('pascalerror:detail', message.slice(0, 120), false);
+      CompositeTreeNode.addChild(errNode, detail);
+      this.model.root = errNode;
+    }
+  }
+
+  protected async readProject(root: URI): Promise<{ name?: string; main?: string; output?: string }> {
+    try {
+      const raw = JSON.parse((await this.files.read(root.resolve('pascal.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 === 'pascal.json' || name.endsWith('.pas') || name.endsWith('.pp') || name.endsWith('.p')) {
+        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 PascalTreeNode | 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<PascalTreeNode>;
+    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('pascal.json')) {
+        return React.createElement('div', { className: codicon('json') });
+      }
+      return React.createElement('div', { className: codicon('file-code') });
+    }
+    return super.renderIcon(node, props);
+  }
+}
+
+export const PASCAL_EXPLORER_VIEW_CONTAINER_ID = 'pascal-explorer-view-container';
+export const PASCAL_EXPLORER_VIEW_CONTAINER_TITLE_OPTIONS: ViewContainerTitleOptions = {
+  label: 'Pascal Project',
+  iconClass: codicon('folder-library'),
+  closeable: true,
+};
+
+/** Dedicated activity-bar container for the project view (with its icon). */
+@injectable()
+export class PascalExplorerContainerFactory {
+  static ID = PASCAL_EXPLORER_VIEW_CONTAINER_ID;
+  readonly id = PascalExplorerContainerFactory.ID;
+  @inject(ViewContainer.Factory) protected readonly viewContainerFactory: ViewContainer.Factory;
+  @inject(WidgetManager) protected readonly widgetManager: WidgetManager;
+  async createWidget(): Promise<Widget> {
+    const viewContainer = this.viewContainerFactory({
+      id: PASCAL_EXPLORER_VIEW_CONTAINER_ID,
+      progressLocationId: 'pascal-explorer',
+    });
+    viewContainer.setTitleOptions(PASCAL_EXPLORER_VIEW_CONTAINER_TITLE_OPTIONS);
+    const explorer = await this.widgetManager.getOrCreateWidget(PASCAL_EXPLORER_ID);
+    viewContainer.addWidget(explorer, {
+      order: 0, canHide: false, initiallyCollapsed: false, weight: 100,
+    });
+    return viewContainer;
+  }
+}
+
+export class PascalExplorerContribution extends AbstractViewContribution<PascalExplorerWidget> {
+  constructor() {
+    super({
+      viewContainerId: PASCAL_EXPLORER_VIEW_CONTAINER_ID,
+      widgetId: PASCAL_EXPLORER_ID,
+      widgetName: 'Pascal Project',
+      defaultWidgetOptions: { area: 'left', rank: 300 },
+      toggleCommandId: 'pascalExplorer:toggle',
+    });
+  }
+
+  async initializeLayout(): Promise<void> {
+    await this.openView({ activate: false, reveal: true });
+  }
+}
+
+export const RefreshExplorerCommand: Command = Command.toLocalizedCommand({
+  id: 'pascal.refreshExplorer', label: 'Refresh Project Explorer', category: 'Pascal',
+});
+
+export const NewUnitHereCommand: Command = Command.toLocalizedCommand({
+  id: 'pascal.newUnitHere', label: 'New Unit Here...', category: 'Pascal',
+});
+
+export const NewFolderHereCommand: Command = Command.toLocalizedCommand({
+  id: 'pascal.newFolderHere', label: 'New Folder Here...', category: 'Pascal',
+});
+
+@injectable()
+export class PascalExplorerCommands implements CommandContribution {
+  @inject(WidgetManager) protected readonly widgets: WidgetManager;
+  @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;
+
+  registerCommands(commands: CommandRegistry): void {
+    commands.registerCommand(RefreshExplorerCommand, {
+      execute: () => {
+        for (const widget of this.widgets.getWidgets(PASCAL_EXPLORER_ID)) {
+          void (widget as PascalExplorerWidget).refresh();
+        }
+      },
+    });
+    commands.registerCommand(NewUnitHereCommand, { execute: () => this.newUnitHere() });
+    commands.registerCommand(NewFolderHereCommand, { execute: () => this.newFolderHere() });
+  }
+
+  /** Folder of the selected explorer file, else the workspace root. */
+  protected async selectedDir(): Promise<URI | undefined> {
+    for (const widget of this.widgets.getWidgets(PASCAL_EXPLORER_ID)) {
+      const view = widget as PascalExplorerWidget;
+      const node = view.model.selectedNodes[0] as PascalTreeNode | undefined;
+      if (node?.kind === 'file' && node.uri) {
+        return new URI(node.uri).parent;
+      }
+    }
+    const roots = await this.workspace.roots;
+    return roots[0]?.resource;
+  }
+
+  protected async newUnitHere(): Promise<void> {
+    const dir = await this.selectedDir();
+    if (!dir) {
+      this.messages.warn('Pascal: open a folder or workspace first.');
+      return;
+    }
+    const raw = await this.quickInput.input({
+      prompt: 'Unit name (a Pascal identifier, e.g. MyUnit)',
+      placeHolder: 'MyUnit',
+      validateInput: async value => isValidModuleName(value.trim()) ?? null,
+    });
+    if (raw === undefined) return;
+    const name = raw.trim();
+    const file = `${name}.pas`;
+    const uri = dir.resolve(file);
+    try {
+      if (await this.files.exists(uri)) {
+        this.messages.warn(`Pascal: ${file} already exists, skipped.`);
+        return;
+      }
+      await this.files.writeFile(uri, BinaryBuffer.fromString(renderUnit(name)));
+    } catch (error) {
+      this.messages.error(`Pascal: cannot write ${file}: ${error instanceof Error ? error.message : String(error)}`);
+      return;
+    }
+    this.messages.info(`Pascal: created ${file}.`);
+    try {
+      await this.editors.open(uri, { mode: 'activate' });
+    } catch { /* created is enough */ }
+  }
+
+  protected async newFolderHere(): Promise<void> {
+    const dir = await this.selectedDir();
+    if (!dir) {
+      this.messages.warn('Pascal: open a folder or workspace first.');
+      return;
+    }
+    const raw = await this.quickInput.input({
+      prompt: 'Folder name (e.g. sources)',
+      placeHolder: 'sources',
+      validateInput: async value => {
+        const name = value.trim();
+        if (name.length === 0) return 'Enter a folder name.';
+        if (name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
+          return 'A single folder name, without path separators.';
+        }
+        return null;
+      },
+    });
+    if (raw === undefined) return;
+    const uri = dir.resolve(raw.trim());
+    try {
+      if (await this.files.exists(uri)) {
+        this.messages.warn('Pascal: that folder already exists.');
+        return;
+      }
+      await this.files.createFolder(uri);
+      this.messages.info(`Pascal: created folder ${raw.trim()}.`);
+    } catch (error) {
+      this.messages.error(`Pascal: cannot create folder: ${error instanceof Error ? error.message : String(error)}`);
+    }
+  }
+}
+
+export class PascalExplorerMenus implements MenuContribution {
+  registerMenus(menus: MenuModelRegistry): void {
+    const path = PASCAL_EXPLORER_CONTEXT_MENU;
+    menus.registerMenuAction(path, { commandId: NewUnitHereCommand.id, order: '10' });
+    menus.registerMenuAction(path, { commandId: NewFolderHereCommand.id, order: '11' });
+    menus.registerMenuAction(path, { commandId: 'pascal.build', order: '20' });
+    menus.registerMenuAction(path, { commandId: RefreshExplorerCommand.id, order: '30' });
+  }
+}
+
+export function createPascalExplorerWidget(parent: interfaces.Container): PascalExplorerWidget {
+  const child = createTreeContainer(parent, {
+    widget: PascalExplorerWidget,
+    props: { contextMenuPath: PASCAL_EXPLORER_CONTEXT_MENU },
+  });
+  return child.get(PascalExplorerWidget);
+}

+ 333 - 0
extensions/pascal-project/src/browser/pascal-project-frontend-module.ts

@@ -0,0 +1,333 @@
+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, renderUnit,
+  scaffoldFiles, validateProjectFile,
+} from './pascal-project';
+import {
+  PASCAL_EXPLORER_ID, PascalExplorerCommands, PascalExplorerContainerFactory,
+  PascalExplorerContribution, PascalExplorerMenus, createPascalExplorerWidget,
+} from './pascal-explorer-widget';
+
+const CATEGORY = 'Pascal';
+
+const Commands = {
+  newUnit: Command.toLocalizedCommand({ id: 'pascal.newUnit', label: 'New Unit...', category: CATEGORY }),
+  newProject: Command.toLocalizedCommand({ id: 'pascal.newProject', label: 'New Pascal Project...', category: CATEGORY }),
+  openProject: Command.toLocalizedCommand({ id: 'pascal.openProject', label: 'Open Project...', category: CATEGORY }),
+  switchProject: Command.toLocalizedCommand({ id: 'pascal.switchProject', label: 'Switch Project...', category: CATEGORY }),
+  closeProject: Command.toLocalizedCommand({ id: 'pascal.closeProject', label: 'Close Project', category: CATEGORY }),
+  validate: Command.toLocalizedCommand({ id: 'pascal.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.newUnit, { execute: () => this.newUnit() });
+    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<URI | undefined> {
+    const roots = await this.workspace.roots;
+    if (roots.length === 0) {
+      this.messages.warn('Pascal: 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<string[]> {
+    const declared: string[] = [];
+    try {
+      const content = await this.files.read(root.resolve('pascal.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<string | undefined> {
+    return this.quickInput.input({
+      prompt: 'Unit name (a Pascal identifier, e.g. MyUnit)',
+      placeHolder: 'MyUnit',
+      validateInput: async value => isValidModuleName(value.trim()) ?? null,
+    });
+  }
+
+  protected async newUnit(): Promise<void> {
+    const root = await this.root();
+    if (!root) return;
+    const raw = await this.askModuleName();
+    if (raw === undefined) return;
+    const name = raw.trim();
+    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 unit?' },
+      );
+      if (!picked) return;
+      dir = picked.value;
+    }
+    const file = `${name}.pas`;
+    const uri = dir === '.' ? root.resolve(file) : root.resolve(`${dir}/${file}`);
+    try {
+      if (await this.files.exists(uri)) {
+        this.messages.warn(`Pascal: ${file} already exists, skipped.`);
+        return;
+      }
+      await this.files.writeFile(uri, BinaryBuffer.fromString(renderUnit(name)));
+    } catch (error) {
+      this.messages.error(`Pascal: cannot write ${file}: ${error instanceof Error ? error.message : String(error)}`);
+      return;
+    }
+    this.messages.info(`Pascal: created ${file}.`);
+    const first = dir === '.' ? root.resolve(file) : root.resolve(`${dir}/${file}`);
+    try {
+      await this.editors.open(first, { mode: 'activate' });
+    } catch { /* created is enough */ }
+  }
+
+  protected async newProject(): Promise<void> {
+    const root = await this.root();
+    if (!root) return;
+    const rawName = await this.quickInput.input({
+      prompt: 'Project name (used for the folder and pascal.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;
+    // A new project always lives in its own folder: a sibling of the open
+    // workspace, or the workspace itself when that is still empty.
+    let target = root;
+    let sibling = false;
+    try {
+      const stat = await this.files.resolve(root);
+      if ((stat.children ?? []).length > 0) {
+        target = root.parent.resolve(name);
+        sibling = true;
+      }
+    } catch {
+      target = root.parent.resolve(name);
+      sibling = true;
+    }
+    if (sibling) {
+      try {
+        if (await this.files.exists(target)) {
+          const openIt = await this.quickInput.showQuickPick(
+            [{ label: `Switch to "${name}"`, value: 'switch' },
+             { label: 'Stay here', value: 'stay' }],
+            { placeholder: `"${name}" already exists next to the open folder.` },
+          );
+          if (openIt?.value === 'switch') this.workspace.open(target);
+          return;
+        }
+      } catch { /* fall through and try creating */ }
+    }
+    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(`Pascal: ${f.path} already exists, skipped.`);
+          continue;
+        }
+        await this.files.writeFile(uri, BinaryBuffer.fromString(f.content));
+      }
+    } catch (error) {
+      this.messages.error(`Pascal: 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(`Pascal: created ${template.label} "${name}" in ${where}. Open pascal.json to review, then Pascal: Build.`);
+    if (sibling) {
+      const openIt = await this.quickInput.showQuickPick(
+        [{ label: `Switch to "${name}"`, value: 'switch' },
+         { label: 'Stay here', value: 'stay' }],
+        { placeholder: 'New project created.' },
+      );
+      if (openIt?.value === 'switch') this.workspace.open(target);
+    }
+  }
+
+  /** 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 Pascal 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 }[]>('pascal.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 Pascal 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('pascal.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('pascal.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;
+    const uri = root.resolve('pascal.json');
+    let text: string;
+    try {
+      text = (await this.files.read(uri)).value;
+    } catch {
+      this.messages.warn('Pascal: no pascal.json in the open folder. Create one with Pascal: New Pascal Project.');
+      return;
+    }
+    const problems = validateProjectFile(text);
+    if (problems.length > 0) {
+      this.messages.error(`Pascal: invalid project.\n${problems.join('\n')}`);
+      return;
+    }
+    // Existence checks against the workspace.
+    let raw: { name: string; main: string; sourceDirectories?: string[] };
+    try {
+      raw = JSON.parse(text);
+    } catch {
+      this.messages.error('Pascal: invalid project.\npascal.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'] 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(`Pascal: project "${raw.name}" has problems.\n${missing.join('\n')}`);
+      return;
+    }
+    this.messages.info(`Pascal: project "${raw.name}" is valid (main: ${raw.main}).`);
+  }
+}
+
+class ProjectMenus implements MenuContribution {
+  registerMenus(menus: MenuModelRegistry): void {
+    const submenu = ['menubar', 'pascal'];
+    menus.registerMenuAction(submenu, { commandId: Commands.newUnit.id, order: '20' });
+    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.newUnit.id, order: '11' });
+    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, PascalExplorerContribution);
+  bind(FrontendApplicationContribution).toService(PascalExplorerContribution);
+  bind(WidgetFactory).toDynamicValue(context => ({
+    id: PASCAL_EXPLORER_ID,
+    createWidget: () => createPascalExplorerWidget(context.container),
+  }));
+  bind(PascalExplorerContainerFactory).toSelf().inSingletonScope();
+  bind(WidgetFactory).toService(PascalExplorerContainerFactory);
+  bind(CommandContribution).to(PascalExplorerCommands).inSingletonScope();
+  bind(MenuContribution).to(PascalExplorerMenus).inSingletonScope();
+});

+ 161 - 0
extensions/pascal-project/src/browser/pascal-project.ts

@@ -0,0 +1,161 @@
+/** Pure project logic for the Pascal project extension (testable headlessly).
+ *
+ *  Covers unit/project name validation, file templates, structural
+ *  `pascal.json` validation and candidate source directories. All
+ *  filesystem and UI interaction lives in the frontend module.
+ */
+
+export type ProjectTemplate = 'console' | 'empty' | 'library';
+
+export const TEMPLATES: Array<{ id: ProjectTemplate; label: string; description: string }> = [
+  { id: 'console', label: 'Console application', description: 'Hello-world program with pascal.json' },
+  { id: 'empty', label: 'Empty project', description: 'pascal.json and an empty src directory' },
+  { id: 'library', label: 'Library', description: 'Unit skeleton (interface + implementation)' },
+];
+
+/** Error message for an invalid unit name, or undefined when valid. */
+export function isValidModuleName(name: string): string | undefined {
+  if (!name) return 'Unit name must not be empty.';
+  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
+    return `"${name}" is not a valid Pascal identifier (letters, digits, underscore; must not start with a digit).`;
+  }
+  return undefined;
+}
+
+/** Error message for an invalid project/folder name, or undefined when valid. */
+export function isValidProjectName(name: string): string | undefined {
+  if (!name) return 'Project name must not be empty.';
+  if (/[/\\]/.test(name) || name === '.' || name === '..') {
+    return `"${name}" must be a plain folder name without path separators.`;
+  }
+  return undefined;
+}
+
+/** Derive a unit identifier from an arbitrary project name (`my-lib` -> `MyLib`). */
+export function moduleNameOf(projectName: string): string {
+  const parts = projectName.split(/[^A-Za-z0-9]+/).filter(p => p.length > 0);
+  const camel = parts.map(p => p[0].toUpperCase() + p.slice(1)).join('');
+  const clean = camel.replace(/^[^A-Za-z_]+/, '');
+  return clean || 'Main';
+}
+
+export function renderUnit(name: string): string {
+  return `UNIT ${name};
+
+INTERFACE
+
+IMPLEMENTATION
+
+BEGIN
+  (* TODO *)
+END.
+`;
+}
+
+export function renderMain(name: string): string {
+  return `PROGRAM ${name};
+
+BEGIN
+  WriteLn('Hello, Pascal!');
+END.
+`;
+}
+
+export function renderProjectJson(name: string, main: string): string {
+  return JSON.stringify({
+    name,
+    compiler: { type: 'fpc', path: 'fpc', options: [] },
+    sourceDirectories: ['src'],
+    main,
+  }, undefined, 2) + '\n';
+}
+
+export function renderReadme(name: string): string {
+  return `# ${name}\n\nA Free Pascal project. Build with the Pascal IDE\n(\`Pascal: Build\`) or from the terminal:\n\n\`\`\`bash\nfpc -o bin/${name} src/Main.pas\n\`\`\`\n`;
+}
+
+/** Files (relative to the project root) produced for a template. */
+export function scaffoldFiles(projectName: string, template: ProjectTemplate): Array<{ path: string; content: string }> {
+  const mod = moduleNameOf(projectName);
+  switch (template) {
+    case 'console':
+      return [
+        { path: 'pascal.json', content: renderProjectJson(projectName, 'src/Main.pas') },
+        { path: 'src/Main.pas', content: renderMain(mod === 'Main' ? 'Main' : mod) },
+        { path: 'README.md', content: renderReadme(projectName) },
+      ];
+    case 'empty':
+      return [
+        { path: 'pascal.json', content: renderProjectJson(projectName, 'src/Main.pas') },
+        { path: 'README.md', content: renderReadme(projectName) },
+      ];
+    case 'library':
+      return [
+        { path: 'pascal.json', content: renderProjectJson(projectName, `src/${mod}.pas`) },
+        { path: `src/${mod}.pas`, content: renderUnit(mod) },
+        { path: 'README.md', content: renderReadme(projectName) },
+      ];
+  }
+}
+
+/** Structural problems in a `pascal.json` document (no filesystem access). */
+export function validateProjectFile(jsonText: string): string[] {
+  let raw: {
+    name?: unknown; main?: unknown; output?: unknown;
+    compiler?: { path?: unknown; options?: unknown };
+    sourceDirectories?: unknown;
+  };
+  try {
+    raw = JSON.parse(jsonText);
+  } catch {
+    return ['pascal.json: invalid JSON.'];
+  }
+  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
+    return ['pascal.json: top level must be an object.'];
+  }
+  const problems: string[] = [];
+  if (typeof raw.name !== 'string' || raw.name.length === 0) {
+    problems.push('pascal.json: missing "name" (project name).');
+  }
+  if (typeof raw.main !== 'string' || raw.main.length === 0) {
+    problems.push('pascal.json: missing "main" (main file, e.g. "src/Main.pas").');
+  } else if (!/\.(pas|pp|p)$/.test(raw.main)) {
+    problems.push(`pascal.json: "main" should point at a .pas file (got "${raw.main}").`);
+  }
+  if (raw.output !== undefined && (typeof raw.output !== 'string' || raw.output.length === 0)) {
+    problems.push('pascal.json: "output" must be a non-empty path when present.');
+  }
+  const compiler = (raw as { compiler?: unknown }).compiler;
+  if (compiler !== undefined) {
+    if (typeof compiler !== 'object' || compiler === null) {
+      problems.push('pascal.json: "compiler" must be an object.');
+    } else {
+      const c = compiler as { path?: unknown; options?: unknown };
+      if (c.path !== undefined && (typeof c.path !== 'string' || c.path.length === 0)) {
+        problems.push('pascal.json: "compiler.path" must be a non-empty executable path.');
+      }
+      if (c.options !== undefined &&
+          (!Array.isArray(c.options) || c.options.some(o => typeof o !== 'string'))) {
+        problems.push('pascal.json: "compiler.options" must be an array of strings.');
+      }
+    }
+  }
+  const v = (raw as Record<string, unknown>).sourceDirectories;
+  if (v !== undefined && (!Array.isArray(v) || v.some(d => typeof d !== 'string'))) {
+    problems.push('pascal.json: "sourceDirectories" must be an array of strings.');
+  }
+  return problems;
+}
+
+/** Declared source directories from a `pascal.json` document (tolerant; empty when absent). */
+export function declaredSourceDirs(jsonText: string): string[] {
+  try {
+    const raw = JSON.parse(jsonText) as { sourceDirectories?: unknown };
+    if (Array.isArray(raw.sourceDirectories)) {
+      return raw.sourceDirectories.filter((d): d is string => typeof d === 'string' && d.length > 0);
+    }
+  } catch {
+    // Fall through: no declared directories.
+  }
+  return [];
+}

+ 7 - 3
package.json

@@ -22,11 +22,13 @@
     "extensions/pascal-workbench",
     "extensions/oberon-compiler",
     "extensions/oberon-debug",
-    "extensions/oberon-workbench"
+    "extensions/oberon-workbench",
+    "extensions/pascal-project",
+    "extensions/oberon-project"
   ],
   "scripts": {
     "prepare": "yarn --cwd extensions/modula2-compiler prepare && yarn --cwd extensions/modula2-debug prepare && yarn --cwd extensions/modula2-language prepare && yarn --cwd extensions/modula2-project prepare && yarn --cwd extensions/modula2-workbench prepare",
-    "build": "yarn build:language && yarn build:compiler && yarn build:pascal-compiler && yarn build:debug && yarn build:pascal-debug && yarn build:project && yarn build:workbench && yarn build:pascal-workbench && yarn build:oberon-compiler && yarn build:oberon-debug && yarn build:oberon-workbench && yarn build:browser",
+    "build": "yarn build:language && yarn build:compiler && yarn build:pascal-compiler && yarn build:debug && yarn build:pascal-debug && yarn build:project && yarn build:pascal-project && yarn build:oberon-project && yarn build:workbench && yarn build:pascal-workbench && yarn build:oberon-compiler && yarn build:oberon-debug && yarn build:oberon-workbench && yarn build:browser",
     "build:language": "yarn --cwd extensions/modula2-language build && yarn --cwd extensions/pascal-language build && yarn --cwd extensions/oberon-language build && bash scripts/sync-language-plugin.sh",
     "build:compiler": "yarn --cwd extensions/modula2-compiler build",
     "build:debug": "yarn --cwd extensions/modula2-debug build",
@@ -43,7 +45,9 @@
     "build:pascal-workbench": "yarn --cwd extensions/pascal-workbench build",
     "build:oberon-compiler": "yarn --cwd extensions/oberon-compiler build",
     "build:oberon-debug": "yarn --cwd extensions/oberon-debug build",
-    "build:oberon-workbench": "yarn --cwd extensions/oberon-workbench build"
+    "build:oberon-workbench": "yarn --cwd extensions/oberon-workbench build",
+    "build:pascal-project": "yarn --cwd extensions/pascal-project build",
+    "build:oberon-project": "yarn --cwd extensions/oberon-project build"
   },
   "devDependencies": {
     "electron": "^42.8.1",