From a4262a0612c12d2a4e2578b5580e10c9975a9cb0 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 14 Jan 2025 21:10:15 +0100 Subject: [PATCH] Improve compund logs #237829 (#237922) --- .../output/browser/output.contribution.ts | 59 +++++++++- .../contrib/output/browser/outputView.ts | 57 +--------- .../output/common/outputChannelModel.ts | 106 +++++------------- .../services/output/common/output.ts | 81 +++++++++++++ 4 files changed, 174 insertions(+), 129 deletions(-) diff --git a/src/vs/workbench/contrib/output/browser/output.contribution.ts b/src/vs/workbench/contrib/output/browser/output.contribution.ts index 18d6bdb400e..722776ae86f 100644 --- a/src/vs/workbench/contrib/output/browser/output.contribution.ts +++ b/src/vs/workbench/contrib/output/browser/output.contribution.ts @@ -41,6 +41,10 @@ import { localize, localize2 } from '../../../../nls.js'; import { viewFilterSubmenu } from '../../../browser/parts/views/viewFilter.js'; import { ViewAction } from '../../../browser/parts/views/viewPane.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { basename } from '../../../../base/common/resources.js'; + +const IMPORTED_LOG_ID_PREFIX = 'importedLog.'; // Register Service registerSingleton(IOutputService, OutputService, InstantiationType.Delayed); @@ -114,6 +118,7 @@ class OutputContribution extends Disposable implements IWorkbenchContribution { this.registerConfigureActiveOutputLogLevelAction(); this.registerFilterActions(); this.registerExportLogsAction(); + this.registerImportLogAction(); } private registerSwitchOutputAction(): void { @@ -144,7 +149,7 @@ class OutputContribution extends Disposable implements IWorkbenchContribution { const registerOutputChannels = (channels: IOutputChannelDescriptor[]) => { for (const channel of channels) { const title = channel.label; - const group = channel.files && channel.files.length > 1 ? '2_compound_logs' : channel.extensionId ? '0_ext_outputchannels' : '1_core_outputchannels'; + const group = (channel.files?.length && channel.files.length > 1) || channel.id.startsWith(IMPORTED_LOG_ID_PREFIX) ? '2_custom_logs' : channel.extensionId ? '0_ext_outputchannels' : '1_core_outputchannels'; registeredChannels.set(channel.id, registerAction2(class extends Action2 { constructor() { super({ @@ -186,6 +191,7 @@ class OutputContribution extends Disposable implements IWorkbenchContribution { menu: [{ id: MenuId.ViewTitle, when: ContextKeyExpr.equals('view', OUTPUT_VIEW_ID), + group: '2_add', }], }); } @@ -405,7 +411,7 @@ class OutputContribution extends Disposable implements IWorkbenchContribution { menu: [{ id: MenuId.ViewTitle, when: ContextKeyExpr.equals('view', OUTPUT_VIEW_ID), - group: 'export', + group: '1_export', order: 1 }], }); @@ -711,7 +717,7 @@ class OutputContribution extends Disposable implements IWorkbenchContribution { menu: [{ id: MenuId.ViewTitle, when: ContextKeyExpr.equals('view', OUTPUT_VIEW_ID), - group: 'export', + group: '1_export', order: 2, }], }); @@ -747,6 +753,53 @@ class OutputContribution extends Disposable implements IWorkbenchContribution { } })); } + + private registerImportLogAction(): void { + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: `workbench.action.importLog`, + title: nls.localize2('importLog', "Import Log..."), + f1: true, + category: Categories.Developer, + menu: [{ + id: MenuId.ViewTitle, + when: ContextKeyExpr.equals('view', OUTPUT_VIEW_ID), + group: '2_add', + order: 2, + }], + }); + } + async run(accessor: ServicesAccessor): Promise { + const outputService = accessor.get(IOutputService); + const fileDialogService = accessor.get(IFileDialogService); + const result = await fileDialogService.showOpenDialog({ + title: nls.localize('importLogFile', "Import Log File"), + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: true, + filters: [{ + name: nls.localize('logFiles', "Log Files"), + extensions: ['log'] + }] + }); + + if (result?.length) { + const channelName = basename(result[0]); + const channelId = `${IMPORTED_LOG_ID_PREFIX}${Date.now()}`; + // Register and show the channel + Registry.as(Extensions.OutputChannels).registerChannel({ + id: channelId, + label: channelName, + log: true, + files: result, + fileNames: result.map(r => basename(r).split('.')[0]) + }); + outputService.showChannel(channelId); + } + } + })); + } } Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(OutputContribution, LifecyclePhase.Restored); diff --git a/src/vs/workbench/contrib/output/browser/outputView.ts b/src/vs/workbench/contrib/output/browser/outputView.ts index c6d759974ad..b7a82fdb378 100644 --- a/src/vs/workbench/contrib/output/browser/outputView.ts +++ b/src/vs/workbench/contrib/output/browser/outputView.ts @@ -13,7 +13,7 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { IContextKeyService, IContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { AbstractTextResourceEditor } from '../../../browser/parts/editor/textResourceEditor.js'; -import { OUTPUT_VIEW_ID, CONTEXT_IN_OUTPUT, IOutputChannel, CONTEXT_OUTPUT_SCROLL_LOCK, IOutputService, IOutputViewFilters, OUTPUT_FILTER_FOCUS_CONTEXT, LOG_ENTRY_REGEX } from '../../../services/output/common/output.js'; +import { OUTPUT_VIEW_ID, CONTEXT_IN_OUTPUT, IOutputChannel, CONTEXT_OUTPUT_SCROLL_LOCK, IOutputService, IOutputViewFilters, OUTPUT_FILTER_FOCUS_CONTEXT, LOG_ENTRY_REGEX, parseLogEntries, ILogEntry } from '../../../services/output/common/output.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; @@ -340,12 +340,6 @@ export class OutputEditor extends AbstractTextResourceEditor { } - -interface ILogEntry { - readonly logLevel: LogLevel; - readonly lineRange: [number, number]; -} - export class FilterController extends Disposable implements IEditorContribution { public static readonly ID = 'output.editor.contrib.filterController'; @@ -413,30 +407,8 @@ export class FilterController extends Disposable implements IEditorContribution } private computeLogEntriesIncremental(model: ITextModel, fromLine: number): void { - if (!this.logEntries) { - return; - } - - const lineCount = model.getLineCount(); - for (let lineNumber = fromLine; lineNumber <= lineCount; lineNumber++) { - const lineContent = model.getLineContent(lineNumber); - const match = LOG_ENTRY_REGEX.exec(lineContent); - if (match) { - const logLevel = this.parseLogLevel(match[3]); - const startLine = lineNumber; - let endLine = lineNumber; - - while (endLine < lineCount) { - const nextLineContent = model.getLineContent(endLine + 1); - if (model.getLineFirstNonWhitespaceColumn(endLine + 1) === 0 || LOG_ENTRY_REGEX.test(nextLineContent)) { - break; - } - endLine++; - } - - this.logEntries.push({ logLevel, lineRange: [startLine, endLine] }); - lineNumber = endLine; - } + if (this.logEntries) { + this.logEntries = this.logEntries.concat(parseLogEntries(model, fromLine)); } } @@ -456,17 +428,17 @@ export class FilterController extends Disposable implements IEditorContribution for (let i = from; i < this.logEntries.length; i++) { const entry = this.logEntries[i]; if (hasLogLevelFilter && !this.shouldShowEntry(entry, filters)) { - this.hiddenAreas.push(new Range(entry.lineRange[0], 1, entry.lineRange[1], model.getLineMaxColumn(entry.lineRange[1]))); + this.hiddenAreas.push(entry.range); continue; } if (filters.text) { - const matches = model.findMatches(filters.text, new Range(entry.lineRange[0], 1, entry.lineRange[1], model.getLineLastNonWhitespaceColumn(entry.lineRange[1])), false, false, null, false); + const matches = model.findMatches(filters.text, entry.range, false, false, null, false); if (matches.length) { for (const match of matches) { findMatchesDecorations.push({ range: match.range, options: FindDecorations._FIND_MATCH_DECORATION }); } } else { - this.hiddenAreas.push(new Range(entry.lineRange[0], 1, entry.lineRange[1], model.getLineMaxColumn(entry.lineRange[1]))); + this.hiddenAreas.push(entry.range); } } } @@ -509,21 +481,4 @@ export class FilterController extends Disposable implements IEditorContribution } return true; } - - private parseLogLevel(level: string): LogLevel { - switch (level.toLowerCase()) { - case 'trace': - return LogLevel.Trace; - case 'debug': - return LogLevel.Debug; - case 'info': - return LogLevel.Info; - case 'warning': - return LogLevel.Warning; - case 'error': - return LogLevel.Error; - default: - throw new Error(`Unknown log level: ${level}`); - } - } } diff --git a/src/vs/workbench/contrib/output/common/outputChannelModel.ts b/src/vs/workbench/contrib/output/common/outputChannelModel.ts index a942ccaf3c1..125f6494c09 100644 --- a/src/vs/workbench/contrib/output/common/outputChannelModel.ts +++ b/src/vs/workbench/contrib/output/common/outputChannelModel.ts @@ -21,9 +21,9 @@ import { Range } from '../../../../editor/common/core/range.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { ILogger, ILoggerService, ILogService } from '../../../../platform/log/common/log.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { LOG_ENTRY_REGEX, OutputChannelUpdateMode } from '../../../services/output/common/output.js'; +import { LOG_ENTRY_REGEX, LOG_MIME, OutputChannelUpdateMode, parseLogEntryAt } from '../../../services/output/common/output.js'; import { isCancellationError } from '../../../../base/common/errors.js'; -import { binarySearch } from '../../../../base/common/arrays.js'; +import { TextModel } from '../../../../editor/common/model/textModel.js'; export interface IOutputChannelModel extends IDisposable { readonly onDispose: Event; @@ -168,6 +168,7 @@ class MultiFileContentProvider extends Disposable implements IContentProvider { constructor( filesInfos: IOutputChannelFileInfo[], + @IInstantiationService private readonly instantiationService: IInstantiationService, @IFileService fileService: IFileService, @ILogService logService: ILogService, ) { @@ -194,12 +195,37 @@ class MultiFileContentProvider extends Disposable implements IContentProvider { async getContent(): Promise<{ readonly content: string; readonly consume: () => void }> { const outputs = await Promise.all(this.fileOutputs.map(output => output.getContent())); - const content = combineLogEntries(outputs); + const content = this.combineLogEntries(outputs); return { content, consume: () => outputs.forEach(({ consume }) => consume()) }; } + + private combineLogEntries(outputs: { content: string; name: string }[]): string { + + const logEntries: [number, string][] = []; + + for (const { content, name } of outputs) { + const model = this.instantiationService.createInstance(TextModel, content, LOG_MIME, TextModel.DEFAULT_CREATION_OPTIONS, null); + for (let lineNumber = 1; lineNumber <= model.getLineCount(); lineNumber++) { + const logEntry = parseLogEntryAt(model, lineNumber); + if (!logEntry) { + continue; + } + lineNumber = logEntry.range.endLineNumber; + const content = model.getValueInRange(logEntry.range).replace(LOG_ENTRY_REGEX, `$1 [${name}] $2`); + logEntries.push([logEntry.timestamp, content]); + } + } + + let result = ''; + for (const [, content] of logEntries.sort((a, b) => a[0] - b[0])) { + result += content + '\n'; + } + return result; + } + } export abstract class AbstractFileOutputChannelModel extends Disposable implements IOutputChannelModel { @@ -440,8 +466,9 @@ export class MultiFileOutputChannelModel extends AbstractFileOutputChannelModel @IModelService modelService: IModelService, @ILogService logService: ILogService, @IEditorWorkerService editorWorkerService: IEditorWorkerService, + @IInstantiationService instantiationService: IInstantiationService, ) { - const multifileOutput = new MultiFileContentProvider(filesInfos, fileService, logService); + const multifileOutput = new MultiFileContentProvider(filesInfos, instantiationService, fileService, logService); super(modelUri, language, multifileOutput, modelService, editorWorkerService); this.multifileOutput = this._register(multifileOutput); } @@ -549,74 +576,3 @@ export class DelegatedOutputChannelModel extends Disposable implements IOutputCh this.outputChannelModel.then(outputChannelModel => outputChannelModel.replace(value)); } } - -function combineLogEntries(outputs: { content: string; name: string }[]): string { - const timestampEntries: Date[] = []; - const combinedEntries: string[] = []; - - let startTimestampOfLastOutput: Date | undefined; - let endTimestampOfLastOutput: Date | undefined; - - for (const output of outputs) { - let startTimestamp: Date | undefined; - let timestamp: Date | undefined; - const logEntries = output.content.split('\n'); - for (let index = 0; index < logEntries.length; index++) { - const entry = logEntries[index]; - if (!entry.trim()) { - continue; - } - timestamp = new Date(entry.match(LOG_ENTRY_REGEX)?.[1]!); - if (!startTimestamp) { - startTimestamp = timestamp; - } - const entriesToAdd = [entry.replace(LOG_ENTRY_REGEX, `$1 [${output.name}] $2`)]; - const timestampsToAdd = [timestamp]; - - if (startTimestampOfLastOutput && timestamp < startTimestampOfLastOutput) { - for (index = index + 1; index < logEntries.length; index++) { - const entry = logEntries[index]; - if (!entry.trim()) { - continue; - } - timestamp = new Date(entry.match(LOG_ENTRY_REGEX)?.[1]!); - if (timestamp > startTimestampOfLastOutput) { - index--; - break; - } - entriesToAdd.push(entry.replace(LOG_ENTRY_REGEX, `$1 [${output.name}] $2`)); - timestampsToAdd.push(timestamp); - } - combinedEntries.unshift(...entriesToAdd); - timestampEntries.unshift(...timestampsToAdd); - continue; - } - - if (endTimestampOfLastOutput && timestamp > endTimestampOfLastOutput) { - for (index = index + 1; index < logEntries.length; index++) { - const entry = logEntries[index]; - if (!entry.trim()) { - continue; - } - timestamp = new Date(entry.match(LOG_ENTRY_REGEX)?.[1]!); - entriesToAdd.push(entry.replace(LOG_ENTRY_REGEX, `$1 [${output.name}] $2`)); - timestampsToAdd.push(timestamp); - } - combinedEntries.push(...entriesToAdd); - timestampEntries.push(...timestampsToAdd); - break; - } - - const idx = binarySearch(timestampEntries, timestamp, (a, b) => a.getTime() - b.getTime()); - const insertionIndex = idx < 0 ? ~idx : idx; - combinedEntries.splice(insertionIndex, 0, ...entriesToAdd); - timestampEntries.splice(insertionIndex, 0, ...timestampsToAdd); - } - - startTimestampOfLastOutput = startTimestamp; - endTimestampOfLastOutput = timestamp; - } - // Add new empty line at the end - combinedEntries.push(''); - return combinedEntries.join('\n'); -} diff --git a/src/vs/workbench/services/output/common/output.ts b/src/vs/workbench/services/output/common/output.ts index eb97f4a4e19..addfef6e93d 100644 --- a/src/vs/workbench/services/output/common/output.ts +++ b/src/vs/workbench/services/output/common/output.ts @@ -8,6 +8,9 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { LogLevel } from '../../../../platform/log/common/log.js'; +import { Range } from '../../../../editor/common/core/range.js'; /** * Mime type used by the output editor. @@ -247,3 +250,81 @@ class OutputChannelRegistry implements IOutputChannelRegistry { } Registry.add(Extensions.OutputChannels, new OutputChannelRegistry()); + +export interface ILogEntry { + readonly timestamp: number; + readonly logLevel: LogLevel; + readonly timestampRange: Range; + readonly range: Range; +} + +/** + * Parses log entries from a given text model starting from a specified line. + * + * @param model - The text model containing the log entries. + * @param fromLine - The line number to start parsing from (default is 1). + * @returns An array of log entries, each containing the log level and the range of lines it spans. + */ +export function parseLogEntries(model: ITextModel, fromLine: number = 1): ILogEntry[] { + const logEntries: ILogEntry[] = []; + for (let lineNumber = fromLine; lineNumber <= model.getLineCount(); lineNumber++) { + const logEntry = parseLogEntryAt(model, lineNumber); + if (logEntry) { + logEntries.push(logEntry); + lineNumber = logEntry.range.endLineNumber; + } + } + return logEntries; +} + + +/** + * Parses a log entry at the specified line number in the given text model. + * + * @param model - The text model containing the log entries. + * @param lineNumber - The line number at which to start parsing the log entry. + * @returns An object representing the parsed log entry, or `null` if no log entry is found at the specified line. + * + * The returned log entry object contains: + * - `timestamp`: The timestamp of the log entry as a number. + * - `logLevel`: The log level of the log entry. + * - `range`: The range of lines that the log entry spans. + */ +export function parseLogEntryAt(model: ITextModel, lineNumber: number): ILogEntry | null { + const lineContent = model.getLineContent(lineNumber); + const match = LOG_ENTRY_REGEX.exec(lineContent); + if (match) { + const timestamp = new Date(match[1]).getTime(); + const timestampRange = new Range(lineNumber, 1, lineNumber, match[1].length + 1); + const logLevel = parseLogLevel(match[3]); + const startLine = lineNumber; + let endLine = lineNumber; + + while (endLine < model.getLineCount()) { + const nextLineContent = model.getLineContent(endLine + 1); + if (model.getLineFirstNonWhitespaceColumn(endLine + 1) === 0 || LOG_ENTRY_REGEX.test(nextLineContent)) { + break; + } + endLine++; + } + return { timestamp, logLevel, range: new Range(startLine, 1, endLine, model.getLineMaxColumn(endLine)), timestampRange }; + } + return null; +} + +function parseLogLevel(level: string): LogLevel { + switch (level.toLowerCase()) { + case 'trace': + return LogLevel.Trace; + case 'debug': + return LogLevel.Debug; + case 'info': + return LogLevel.Info; + case 'warning': + return LogLevel.Warning; + case 'error': + return LogLevel.Error; + default: + throw new Error(`Unknown log level: ${level}`); + } +}