Improve compund logs #237829 (#237922)

This commit is contained in:
Sandeep Somavarapu
2025-01-14 21:10:15 +01:00
committed by GitHub
parent ac721a2d45
commit a4262a0612
4 changed files with 174 additions and 129 deletions
@@ -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<void> {
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<IOutputChannelRegistry>(Extensions.OutputChannels).registerChannel({
id: channelId,
label: channelName,
log: true,
files: result,
fileNames: result.map(r => basename(r).split('.')[0])
});
outputService.showChannel(channelId);
}
}
}));
}
}
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(OutputContribution, LifecyclePhase.Restored);
@@ -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}`);
}
}
}
@@ -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<void>;
@@ -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');
}
@@ -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}`);
}
}