소스 검색

Build report dialog with project statistics

- centered Build succeeded/failed dialog with green check / red
  error icon, file/line counts and executable size plus an OK button
- backend collects source census (.mod/.def files and lines) and
  output binary size; formatBytes helper pure and tested
- build and run output also goes to a Modula-2 output channel
Eric Streit 1 주 전
부모
커밋
70a00d0d59

+ 101 - 0
extensions/modula2-compiler/src/browser/modula2-build-dialog.ts

@@ -0,0 +1,101 @@
+import { codicon } from '@theia/core/lib/browser';
+import { AbstractDialog, DialogProps } from '@theia/core/lib/browser';
+import { BuildResult, BuildStats, formatBytes } from '../common/compiler';
+
+/** Centered build report with a status icon, statistics and an OK button. */
+export class BuildReportDialog extends AbstractDialog<void> {
+  protected readonly success: boolean;
+  protected readonly rows: Array<[string, string]>;
+  protected readonly details?: string;
+
+  constructor(result: BuildResult, projectName: string) {
+    super({
+      title: result.success ? 'Build succeeded' : 'Build failed',
+      maxWidth: 480,
+    } as DialogProps);
+    this.success = result.success;
+    this.rows = reportRows(result, projectName);
+    this.details = result.success ? undefined : failureDetails(result);
+    this.contentNode.appendChild(this.renderBody());
+    const ok = this.appendAcceptButton('OK');
+    ok.classList.add('main');
+  }
+
+  get value(): void {
+    return undefined;
+  }
+
+  protected renderBody(): HTMLElement {
+    const wrap = document.createElement('div');
+    wrap.style.display = 'flex';
+    wrap.style.gap = '16px';
+    wrap.style.alignItems = 'flex-start';
+    wrap.style.padding = '4px 2px';
+    const icon = document.createElement('div');
+    icon.className = codicon(this.success ? 'check' : 'error');
+    icon.style.fontSize = '40px';
+    icon.style.color = this.success ? '#89D185' : '#F14C4C';
+    icon.style.lineHeight = '1';
+    wrap.appendChild(icon);
+    const right = document.createElement('div');
+    right.style.flex = '1';
+    right.style.minWidth = '0';
+    for (const [label, value] of this.rows) {
+      const row = document.createElement('div');
+      row.style.display = 'flex';
+      row.style.justifyContent = 'space-between';
+      row.style.gap = '24px';
+      row.style.padding = '2px 0';
+      const left = document.createElement('span');
+      left.textContent = label;
+      left.style.opacity = '0.7';
+      const val = document.createElement('span');
+      val.textContent = value;
+      val.style.fontWeight = '600';
+      val.style.textAlign = 'right';
+      row.appendChild(left);
+      row.appendChild(val);
+      right.appendChild(row);
+    }
+    if (this.details) {
+      const pre = document.createElement('pre');
+      pre.textContent = this.details;
+      pre.style.marginTop = '12px';
+      pre.style.maxHeight = '160px';
+      pre.style.overflow = 'auto';
+      pre.style.fontSize = '12px';
+      pre.style.whiteSpace = 'pre-wrap';
+      right.appendChild(pre);
+    }
+    wrap.appendChild(right);
+    return wrap;
+  }
+}
+
+function reportRows(result: BuildResult, projectName: string): Array<[string, string]> {
+  const errors = result.diagnostics.filter(d => d.severity === 'error').length;
+  const warnings = result.diagnostics.filter(d => d.severity === 'warning').length;
+  if (!result.success) {
+    return [
+      ['Project', projectName],
+      ['Errors', String(errors)],
+      ['Warnings', String(warnings)],
+    ];
+  }
+  const stats: BuildStats | undefined = result.stats;
+  return [
+    ['Project', projectName],
+    ['Sources', stats ? `${stats.files} Modula-2 file${stats.files === 1 ? '' : 's'}` : '—'],
+    ['Lines', stats ? stats.lines.toLocaleString('en-US') : '—'],
+    ['Executable', stats ? formatBytes(stats.outputSize) : '—'],
+  ];
+}
+
+function failureDetails(result: BuildResult): string {
+  const lines = result.diagnostics.slice(0, 5)
+    .map(d => `${d.file}:${d.line + 1}:${d.character + 1}: ${d.message}`);
+  if (lines.length === 0 && result.output.trim().length > 0) {
+    return result.output.trim().split('\n').slice(0, 5).join('\n');
+  }
+  return lines.join('\n');
+}

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

@@ -22,6 +22,7 @@ import {
   BuildDiagnostic, BuildResult, Modula2Compiler, Modula2CompilerServicePath,
   Modula2Project,
 } from '../common/compiler';
+import { BuildReportDialog } from './modula2-build-dialog';
 import { COMPILER_OPTIONS, COMPILER_PATH, modula2PreferenceSchema } from './modula2-preferences';
 import { loadWorkspaceProject } from './modula2-project';
 import { Modula2TaskContribution, Modula2TaskProvider, Modula2TaskResolver } from './modula2-tasks';
@@ -149,14 +150,7 @@ class CompilerCommands implements CommandContribution {
     const result = await this.compiler.build(project);
     this.setProblemMarkers(project, result.diagnostics);
     this.logCommand(result.command, result.output);
-    if (result.success) {
-      this.messages.info(`Modula-2: build succeeded. ${this.summarize(result)}`);
-    } else {
-      this.outputChannels.getChannel('Modula-2').show();
-      const first = result.diagnostics.slice(0, 3)
-        .map(d => `${d.file}:${d.line + 1}:${d.character + 1}: ${d.message}`).join('\n');
-      this.messages.error(`Modula-2: build failed. ${this.summarize(result)}${first ? '\n' + first : ''}`);
-    }
+    await new BuildReportDialog(result, project.name).open();
   }
 
   /** Build the Modula-2 file in the active editor instead of the project main. */

+ 21 - 0
extensions/modula2-compiler/src/common/compiler.ts

@@ -29,6 +29,27 @@ export interface BuildResult {
   /** Combined stdout/stderr of the compiler. */
   output: string;
   diagnostics: BuildDiagnostic[];
+  /** Project source statistics and output size (present on success). */
+  stats?: BuildStats;
+}
+
+/** Source census for the build report dialog. */
+export interface BuildStats {
+  /** Number of `.mod`/`.def` files under the project root. */
+  files: number;
+  /** Total lines across those files. */
+  lines: number;
+  /** Built executable size in bytes (-1 when unavailable). */
+  outputSize: number;
+}
+
+/** Human-readable byte count (`45.2 KB`, `3.1 MB`). Pure and testable. */
+export function formatBytes(bytes: number): string {
+  if (!Number.isFinite(bytes) || bytes < 0) return '—';
+  if (bytes < 1024) return `${bytes} B`;
+  const kb = bytes / 1024;
+  if (kb < 1024) return `${kb.toFixed(1)} KB`;
+  return `${(kb / 1024).toFixed(1)} MB`;
 }
 
 export interface CompilerOptions {

+ 44 - 2
extensions/modula2-compiler/src/node/compiler-service.ts

@@ -10,7 +10,7 @@ import { spawn } from 'child_process';
 import * as fs from 'fs';
 import * as path from 'path';
 import {
-  BuildDiagnostic, BuildResult, CompilerInfo, Modula2Compiler,
+  BuildDiagnostic, BuildResult, BuildStats, CompilerInfo, Modula2Compiler,
   Modula2Project, defaultOutput,
 } from '../common/compiler';
 
@@ -19,6 +19,46 @@ export interface ProcessResult {
   output: string;
 }
 
+/** Source census plus output size for the build report (best-effort). */
+export async function collectBuildStats(
+  project: Modula2Project, out: string,
+): Promise<BuildStats> {
+  let files = 0;
+  let lines = 0;
+  const visit = async (dir: string, depth: number): Promise<void> => {
+    if (depth > 8) return;
+    let entries: import('fs').Dirent[];
+    try {
+      entries = await fs.promises.readdir(dir, { withFileTypes: true });
+    } catch {
+      return;
+    }
+    for (const entry of entries) {
+      const full = path.join(dir, entry.name);
+      if (entry.isDirectory()) {
+        if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
+        await visit(full, depth + 1);
+      } else if (entry.name.endsWith('.mod') || entry.name.endsWith('.def')) {
+        files++;
+        try {
+          const text = await fs.promises.readFile(full, 'utf8');
+          lines += text.split('\n').length;
+        } catch {
+          // Count the file even when its lines cannot be read.
+        }
+      }
+    }
+  };
+  await visit(project.root, 0);
+  let outputSize = -1;
+  try {
+    outputSize = (await fs.promises.stat(path.join(project.root, out))).size;
+  } catch {
+    // Leave unavailable.
+  }
+  return { files, lines, outputSize };
+}
+
 export function runProcess(command: string, args: string[], cwd: string): Promise<ProcessResult> {
   return new Promise((resolve, reject) => {
     const child = spawn(command, args, { cwd, shell: false });
@@ -79,12 +119,14 @@ export class GnuModula2CompilerService implements Modula2Compiler {
         diagnostics: [],
       };
     }
+    const success = result.exitCode === 0;
     return {
-      success: result.exitCode === 0,
+      success,
       exitCode: result.exitCode,
       command,
       output: result.output,
       diagnostics: parseGm2Diagnostics(result.output),
+      stats: success ? await collectBuildStats(project, out) : undefined,
     };
   }