Quellcode durchsuchen

Active project: double-click/menu selection drives build, run and menus

Eric Streit vor 2 Tagen
Ursprung
Commit
d3fe1fe507

+ 19 - 3
extensions/modula2-compiler/src/browser/modula2-compiler-frontend-module.ts

@@ -16,6 +16,7 @@ import { Diagnostic, DiagnosticSeverity } from '@theia/core/shared/vscode-langua
 import { PreferenceContribution } from '@theia/core/lib/common/preferences/preference-schema';
 import { PreferenceService } from '@theia/core/lib/common/preferences/preference-service';
 import { OutputChannelManager } from '@theia/output/lib/browser/output-channel';
+import { StorageService } from '@theia/core/lib/browser/storage-service';
 import { TaskContribution } from '@theia/task/lib/browser/task-contribution';
 import URI from '@theia/core/lib/common/uri';
 import {
@@ -59,6 +60,7 @@ class CompilerCommands implements CommandContribution {
   @inject(ProblemManager) protected readonly problems: ProblemManager;
   @inject(PreferenceService) protected readonly prefs: PreferenceService;
   @inject(OutputChannelManager) protected readonly outputChannels: OutputChannelManager;
+  @inject(StorageService) protected readonly storage: StorageService;
   /** URIs currently carrying our markers, so stale ones can be cleared. */
   protected readonly markedUris = new Set<string>();
 
@@ -73,9 +75,9 @@ class CompilerCommands implements CommandContribution {
     commands.registerCommand(Commands.run, { execute: () => this.withProject(p => this.run(p)) });
   }
 
-  /** Load modula2.json from the first workspace root, applying preferences. */
+  /** Load modula2.json from the active project, applying preferences. */
   protected async loadProject(): Promise<Modula2Project | undefined> {
-    const loaded = await loadWorkspaceProject(this.files, this.workspace);
+    const loaded = await loadWorkspaceProject(this.files, this.workspace, await this.activeRootUri());
     if (!loaded) {
       this.messages.warn('Modula-2: open a folder with a modula2.json project first.');
       return undefined;
@@ -101,6 +103,19 @@ class CompilerCommands implements CommandContribution {
     }
   }
 
+  /** Stored active project root URI, when it still names an open root. */
+  protected async activeRootUri(): Promise<string | undefined> {
+    try {
+      const roots = await this.workspace.roots;
+      if (roots.length === 0) return undefined;
+      const stored = await this.storage.getData<string>('modula2.activeProject', '');
+      if (stored && roots.some(r => r.resource.toString() === stored)) return stored;
+      return roots[0].resource.toString();
+    } catch {
+      return undefined;
+    }
+  }
+
   /** Publish build diagnostics as Problems markers (click-to-navigate). */
   protected setProblemMarkers(project: Modula2Project, diagnostics: BuildDiagnostic[]): void {
     this.clearProblemMarkers();
@@ -162,7 +177,8 @@ class CompilerCommands implements CommandContribution {
     }
     const project = await this.loadProject();
     if (!project) return;
-    const root = (await this.workspace.roots)[0].resource;
+    const activeRoot = await this.activeRootUri();
+    const root = activeRoot ? new URI(activeRoot) : (await this.workspace.roots)[0].resource;
     const relative = root.relative(uri)?.toString() ?? uri.path.toString();
     await this.build({ ...project, main: relative });
   }

+ 9 - 2
extensions/modula2-compiler/src/browser/modula2-project.ts

@@ -1,15 +1,22 @@
 import { FileService } from '@theia/filesystem/lib/browser/file-service';
 import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
+import URI from '@theia/core/lib/common/uri';
 import { Modula2Project, parseProjectFile } from '../common/compiler';
 
-/** Load modula2.json from the first workspace root (shared by commands/tasks). */
+/** Load modula2.json from the active workspace root (shared by commands/tasks).
+ *
+ *  `activeRootUri` is the `modula2.activeProject` storage value when it
+ *  still names an open root; callers fall back to the first root. */
 export async function loadWorkspaceProject(
   files: FileService,
   workspace: WorkspaceService,
+  activeRootUri?: string,
 ): Promise<{ project: Modula2Project; rootUri: string } | undefined> {
   const roots = await workspace.roots;
   if (roots.length === 0) return undefined;
-  const rootUri = roots[0].resource;
+  const rootUri = (activeRootUri && roots.some(r => r.resource.toString() === activeRootUri))
+    ? new URI(activeRootUri)
+    : roots[0].resource;
   try {
     const content = await files.read(rootUri.resolve('modula2.json'));
     return { project: parseProjectFile(rootUri.path.fsPath(), content.value), rootUri: rootUri.toString() };

+ 147 - 32
extensions/modula2-project/src/browser/modula2-explorer-widget.ts

@@ -12,11 +12,12 @@ 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 { StorageService } from '@theia/core/lib/browser/storage-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 './modula2-explorer-model';
-import { isValidModuleName, renderDefModule, renderImplModule } from './modula2-project';
+import { ACTIVE_PROJECT_KEY, isValidModuleName, renderDefModule, renderImplModule } from './modula2-project';
 
 export const MODULA2_EXPLORER_ID = 'modula2-explorer';
 
@@ -27,11 +28,15 @@ export interface Modula2TreeNode extends CompositeTreeNode, SelectableTreeNode,
   kind: 'project' | 'group' | 'file';
   uri?: string;
   openable: boolean;
+  /** Workspace root this node belongs to (project nodes: the root itself). */
+  rootUri?: string;
+  /** True for the project node builds and menus operate on. */
+  active?: boolean;
 }
 
-function folderNode(id: string, name: string, kind: 'project' | 'group'): Modula2TreeNode {
+function folderNode(id: string, name: string, kind: 'project' | 'group', rootUri?: string, active?: boolean): Modula2TreeNode {
   return {
-    id, name, kind, openable: false,
+    id, name, kind, openable: false, rootUri, active,
     parent: undefined as unknown as CompositeTreeNode,
     children: [],
     expanded: true,
@@ -40,10 +45,10 @@ function folderNode(id: string, name: string, kind: 'project' | 'group'): Modula
 }
 
 function fileNode(
-  uri: string, name: string, openable: boolean,
+  uri: string, name: string, openable: boolean, rootUri?: string,
 ): Modula2TreeNode {
   return {
-    id: uri, name, kind: 'file', uri, openable,
+    id: uri, name, kind: 'file', uri, openable, rootUri,
     parent: undefined as unknown as CompositeTreeNode,
     children: [],
     expanded: false,
@@ -58,6 +63,7 @@ export class Modula2ExplorerWidget extends TreeWidget {
   @inject(FileService) protected readonly files: FileService;
   @inject(WorkspaceService) protected readonly workspace: WorkspaceService;
   @inject(EditorManager) protected readonly editors: EditorManager;
+  @inject(StorageService) protected readonly storage: StorageService;
 
   protected refreshTimer: ReturnType<typeof setTimeout> | undefined;
   protected attached = false;
@@ -85,7 +91,9 @@ export class Modula2ExplorerWidget extends TreeWidget {
     // base TreeWidget.init() (row-update wiring) must be called explicitly.
     super.init();
     // Injections are complete here (never touch them in the constructor).
+    this.ensureStyle();
     this.toDispose.push(this.model.onSelectionChanged(nodes => this.openSelected(nodes)));
+    this.toDispose.push(this.model.onOpenNode(node => this.openNode(node as Modula2TreeNode | undefined)));
     this.toDispose.push(this.files.onDidFilesChange(() => this.scheduleRefresh()));
     this.toDispose.push(this.workspace.onWorkspaceChanged(() => this.refresh()));
     this.toDispose.push(this.workspace.onWorkspaceLocationChanged(() => this.refresh()));
@@ -116,44 +124,114 @@ export class Modula2ExplorerWidget extends TreeWidget {
     try {
       const roots = await this.workspace.roots;
       if (roots.length === 0) {
-        this.model.root = folderNode('m2root', 'No folder open', 'project');
+        this.model.root = folderNode('modula2root', '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);
+      const activeUri = await this.activeRootUri(roots.map(r => r.resource.toString()));
+      const top = folderNode('modula2root', 'Modula-2 Projects', 'project');
+      for (const { resource: root } of roots) {
+        const uri = root.toString();
+        const active = uri === activeUri;
+        const project = await this.readProject(root);
+        const found = await this.collectFiles(root, root, 0, [], project.output);
+        const groups = groupExplorerFiles(found, project.main);
+        const title = project.name ?? `${root.path.base} (no modula2.json)`;
+        const rootNode = folderNode(
+          `modula2root:${uri}`,
+          active ? `${title} (active)` : title,
+          'project',
+          uri,
+          active,
+        );
+        for (const group of groups) {
+          const groupNode = folderNode(`modula2group:${uri}:${group.id}`, group.label, 'group', uri);
+          const fileNodes = group.files.map(f => fileNode(
+            f.uri,
+            f.main ? `${f.name} (main)` : f.name,
+            f.openable,
+            uri,
+          ));
+          CompositeTreeNode.addChildren(groupNode, fileNodes);
+          CompositeTreeNode.addChild(rootNode, groupNode);
+        }
+        if (rootNode.children.length === 0) {
+          const hint = folderNode(`modula2empty:${uri}`, 'No Modula-2 files found', 'group', uri);
+          hint.expanded = false;
+          CompositeTreeNode.addChild(rootNode, hint);
+        }
+        CompositeTreeNode.addChild(top, rootNode);
       }
-      this.model.root = rootNode;
+      this.model.root = top;
     } catch (error) {
       const message = error instanceof Error ? error.message : String(error);
       console.error('Modula-2 explorer refresh failed:', message);
-      const errNode = folderNode('m2error', 'Explorer error', 'group');
-      const detail = fileNode('m2error:detail', message.slice(0, 120), false);
+      const errNode = folderNode('modula2error', 'Explorer error', 'group');
+      const detail = fileNode('modula2error:detail', message.slice(0, 120), false);
       CompositeTreeNode.addChild(errNode, detail);
       this.model.root = errNode;
     }
   }
 
+  /** Active project root URI: the stored one when still open, else the first root. */
+  protected async activeRootUri(known?: string[]): Promise<string | undefined> {
+    try {
+      const roots = known ?? (await this.workspace.roots).map(r => r.resource.toString());
+      if (roots.length === 0) return undefined;
+      const stored = await this.storage.getData<string>(ACTIVE_PROJECT_KEY, '');
+      if (stored && roots.includes(stored)) return stored;
+      return roots[0];
+    } catch {
+      return undefined;
+    }
+  }
+
+  /** Make a workspace root the active project (builds and menus follow it). */
+  protected async setActiveProject(rootUri: string): Promise<void> {
+    try {
+      await this.storage.setData(ACTIVE_PROJECT_KEY, rootUri);
+    } catch { /* refresh still shows the marker for this session */ }
+    await this.refresh();
+  }
+
+  /** Double-click/Enter on a project node activates it; files keep single-click reveal. */
+  protected openNode(node: Modula2TreeNode | undefined): void {
+    if (node?.kind === 'project' && node.rootUri) {
+      void this.setActiveProject(node.rootUri);
+    }
+  }
+
+  protected override handleDblClickEvent(node: TreeNode | undefined, event: React.MouseEvent<HTMLElement>): void {
+    // Project nodes are expandable, so a double-click would only
+    // collapse them: activate the project instead.
+    if ((node as Partial<Modula2TreeNode>)?.kind === 'project' && (node as Partial<Modula2TreeNode>)?.rootUri) {
+      event.preventDefault();
+      event.stopPropagation();
+      void this.setActiveProject((node as Modula2TreeNode).rootUri!);
+      return;
+    }
+    super.handleDblClickEvent(node, event);
+  }
+
+  protected override createNodeClassNames(node: TreeNode, props: NodeProps): string[] {
+    const classes = super.createNodeClassNames(node, props);
+    if ((node as Partial<Modula2TreeNode>).active) classes.push('modula2-active-project');
+    return classes;
+  }
+
+  /** Widget-scoped stylesheet: the active project marker. Rows are flex
+   *  measured, so the bolder caption never clips. Injected here (not via
+   *  a CSS import) so the extension build needs no bundler CSS support. */
+  protected ensureStyle(): void {
+    if (document.getElementById('modula2-explorer-style')) return;
+    const element = document.createElement('style');
+    element.id = 'modula2-explorer-style';
+    element.textContent = [
+      '.modula2-project-explorer .theia-TreeNode.modula2-active-project { box-shadow: inset 3px 0 var(--theia-focusBorder); }',
+      '.modula2-project-explorer .theia-TreeNode.modula2-active-project .theia-TreeNodeSegment { font-weight: bold; filter: brightness(1.35); }',
+    ].join('\n');
+    document.head.appendChild(element);
+  }
+
   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;
@@ -280,6 +358,10 @@ export const NewFolderHereCommand: Command = Command.toLocalizedCommand({
   id: 'modula2.newFolderHere', label: 'New Folder Here...', category: 'Modula-2',
 });
 
+export const SetActiveProjectCommand: Command = Command.toLocalizedCommand({
+  id: 'modula2.setActiveProject', label: 'Set as Active Project', category: 'Modula-2',
+});
+
 @injectable()
 export class Modula2ExplorerCommands implements CommandContribution {
   @inject(WidgetManager) protected readonly widgets: WidgetManager;
@@ -288,6 +370,7 @@ export class Modula2ExplorerCommands implements CommandContribution {
   @inject(EditorManager) protected readonly editors: EditorManager;
   @inject(MessageService) protected readonly messages: MessageService;
   @inject(QuickInputService) protected readonly quickInput: QuickInputService;
+  @inject(StorageService) protected readonly storage: StorageService;
 
   registerCommands(commands: CommandRegistry): void {
     commands.registerCommand(RefreshExplorerCommand, {
@@ -299,6 +382,37 @@ export class Modula2ExplorerCommands implements CommandContribution {
     });
     commands.registerCommand(NewModuleHereCommand, { execute: () => this.newModuleHere() });
     commands.registerCommand(NewFolderHereCommand, { execute: () => this.newFolderHere() });
+    commands.registerCommand(SetActiveProjectCommand, { execute: () => this.setActiveProject() });
+  }
+
+  /** Workspace root of the selected explorer node, else the first root. */
+  protected async selectedRoot(): Promise<string | undefined> {
+    for (const widget of this.widgets.getWidgets(MODULA2_EXPLORER_ID)) {
+      const view = widget as Modula2ExplorerWidget;
+      const node = view.model.selectedNodes[0] as Modula2TreeNode | undefined;
+      if (node?.rootUri) return node.rootUri;
+    }
+    const roots = await this.workspace.roots;
+    return roots[0]?.resource.toString();
+  }
+
+  /** Activate a project: double-click a project node, or run this. */
+  protected async setActiveProject(): Promise<void> {
+    const uri = await this.selectedRoot();
+    if (!uri) {
+      this.messages.warn('Modula-2: open a folder or workspace first.');
+      return;
+    }
+    try {
+      await this.storage.setData(ACTIVE_PROJECT_KEY, uri);
+    } catch {
+      this.messages.error('Modula-2: cannot remember the active project.');
+      return;
+    }
+    for (const widget of this.widgets.getWidgets(MODULA2_EXPLORER_ID)) {
+      await (widget as Modula2ExplorerWidget).refresh();
+    }
+    this.messages.info(`Modula-2: active project is now ${new URI(uri).path.base}.`);
   }
 
   /** Folder of the selected explorer file, else the workspace root. */
@@ -395,6 +509,7 @@ export class Modula2ExplorerMenus implements MenuContribution {
     const path = MODULA2_EXPLORER_CONTEXT_MENU;
     menus.registerMenuAction(path, { commandId: NewModuleHereCommand.id, order: '10' });
     menus.registerMenuAction(path, { commandId: NewFolderHereCommand.id, order: '11' });
+    menus.registerMenuAction(path, { commandId: SetActiveProjectCommand.id, order: '12' });
     menus.registerMenuAction(path, { commandId: 'modula2.build', order: '20' });
     menus.registerMenuAction(path, { commandId: RefreshExplorerCommand.id, order: '30' });
   }

+ 8 - 2
extensions/modula2-project/src/browser/modula2-project-frontend-module.ts

@@ -14,13 +14,13 @@ import { WorkspaceCommands } from '@theia/workspace/lib/browser/workspace-comman
 import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
 import URI from '@theia/core/lib/common/uri';
 import {
-  ProjectTemplate, TEMPLATES, declaredSourceDirs, isValidModuleName,
+  ACTIVE_PROJECT_KEY, ProjectTemplate, TEMPLATES, declaredSourceDirs, isValidModuleName,
   isValidProjectName, moduleNameOf, renderDefModule, renderImplModule,
   scaffoldFiles, validateProjectFile,
 } from './modula2-project';
 import {
   MODULA2_EXPLORER_ID, Modula2ExplorerCommands, Modula2ExplorerContainerFactory,
-  Modula2ExplorerContribution, Modula2ExplorerMenus, createModula2ExplorerWidget,
+  Modula2ExplorerContribution, Modula2ExplorerMenus, SetActiveProjectCommand, createModula2ExplorerWidget,
 } from './modula2-explorer-widget';
 
 const CATEGORY = 'Modula-2';
@@ -61,6 +61,11 @@ class ProjectCommands implements CommandContribution {
       this.messages.warn('Modula-2: open a folder first (File > Open Folder).');
       return undefined;
     }
+    try {
+      const stored = await this.storage.getData<string>(ACTIVE_PROJECT_KEY, '');
+      const hit = roots.find(r => r.resource.toString() === stored);
+      if (hit) return hit.resource;
+    } catch { /* fall through to the first root */ }
     return roots[0].resource;
   }
 
@@ -335,6 +340,7 @@ class ProjectMenus implements MenuContribution {
     menus.registerMenuAction(project, { commandId: Commands.switchProject.id, order: '12' });
     menus.registerMenuAction(project, { commandId: Commands.closeProject.id, order: '13' });
     menus.registerMenuAction(project, { commandId: Commands.validate.id, order: '14' });
+    menus.registerMenuAction(project, { commandId: SetActiveProjectCommand.id, order: '15' });
   }
 }
 

+ 3 - 0
extensions/modula2-project/src/browser/modula2-project.ts

@@ -7,6 +7,9 @@
 
 export type ProjectTemplate = 'console' | 'empty' | 'library';
 
+/** StorageService key holding the active project root URI string. */
+export const ACTIVE_PROJECT_KEY = 'modula2.activeProject';
+
 export const TEMPLATES: Array<{ id: ProjectTemplate; label: string; description: string }> = [
   { id: 'console', label: 'Console application', description: 'Hello-world program with modula2.json' },
   { id: 'empty', label: 'Empty project', description: 'modula2.json and an empty src directory' },