diff --git a/package.json b/package.json index 8e8e5cb..9c33f53 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,10 @@ "command": "codechecker.executor.analyzeProject", "title": "CodeChecker: Analyze entire project" }, + { + "command": "codechecker.executor.getFileAnalysisStatus", + "title": "CodeChecker: Get file analysis status" + }, { "command": "codechecker.executor.showCommandLine", "title": "CodeChecker: Show full CodeChecker analyze command line" diff --git a/src/backend/executor/bridge.ts b/src/backend/executor/bridge.ts index 0fad124..f8a809b 100644 --- a/src/backend/executor/bridge.ts +++ b/src/backend/executor/bridge.ts @@ -4,6 +4,9 @@ import { EventEmitter, ExtensionContext, FileSystemWatcher, + ThemeColor, + ThemeIcon, + TreeItemCollapsibleState, Uri, commands, window, @@ -20,6 +23,8 @@ import { import { ProcessStatusType, ProcessType, ScheduledProcess } from '.'; import { NotificationType } from '../../editor/notifications'; import { Editor } from '../../editor'; +import { SidebarContainer } from '../../sidebar'; +import { ReportTreeItem } from '../../sidebar/views'; // Structure: // CodeChecker analyzer version: \n {"base_package_version": "M.m.p", ...} @@ -88,6 +93,9 @@ export class ExecutorBridge implements Disposable { ctx.subscriptions.push( commands.registerCommand('codechecker.executor.analyzeProject', this.analyzeProject, this) ); + ctx.subscriptions.push( + commands.registerCommand('codechecker.executor.getFileAnalysisStatus', this.getFileAnalysisStatus, this) + ); ctx.subscriptions.push( commands.registerCommand('codechecker.executor.runCodeCheckerLog', this.runLogDefaultCommand, this) ); @@ -425,6 +433,127 @@ export class ExecutorBridge implements Disposable { ExtensionApi.executorManager.addToQueue(process, 'replace'); } + public async getFileAnalysisStatus() { + if (!await this.checkVersion()) { + return; + } + if (this.checkedVersion < [ 6, 27, 0 ]) { + const statusNode = SidebarContainer.reportsView.getNodeById('statusItem'); + statusNode?.setLabelAndIcon('Status report requires CodeChecker 6.27.0 or higher.'); + return; + } + + const ccPath = getConfigAndReplaceVariables('codechecker.executor', 'executablePath') || 'CodeChecker'; + const reportsFolder = this.getReportsFolder(); + const fileUri = window.activeTextEditor?.document.uri; + const fsPath = fileUri?.fsPath; + + const statusArgs = [ + 'parse', + '--status', + '--detailed', + '-e', + 'json', + reportsFolder, + '--file', + fsPath ?? '' + ]; + + const process = new ScheduledProcess(ccPath, statusArgs, { processType: ProcessType.status }); + + let processOutput = ''; + process.processStdout((output) => processOutput += output); + process.processStatusChange(async status => { + switch (status.type) { + case ProcessStatusType.errored: + break; + case ProcessStatusType.finished: + interface Analyzer { + summary: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'up-to-date': number, + failed: number, + missing: number, + outdated: number + } + // eslint-disable-next-line @typescript-eslint/naming-convention + 'up-to-date': [], + failed: [], + missing: [], + outdated: [], + } + + const analyzers = JSON.parse(processOutput); + let uptodate = 0; + let outdated = 0; + let missing = 0; + let failed = 0; + const analyzerStatuses: ReportTreeItem[] = []; + if (analyzers.analyzers) { + for (const [ analyzer, status ] of Object.entries(analyzers.analyzers)) { + const s = status as Analyzer; + let iconname = ''; + if (s.summary['up-to-date'] > 0) { + uptodate++; + iconname = 'check'; + } + if (s.summary.outdated > 0) { + outdated++; + iconname = 'clock'; + } + if (s.summary.failed > 0) { + failed++; + iconname = 'error'; + } + if (s.summary.missing > 0) { + missing++; + iconname = 'question'; + } + let existingStatusNode = SidebarContainer.reportsView.getNodeById(analyzer); + if (!existingStatusNode) { + existingStatusNode = new ReportTreeItem(analyzer, analyzer, + new ThemeIcon(iconname), []); + const report = SidebarContainer.reportsView.getNodeById('statusItem'); + existingStatusNode.parent = report; + SidebarContainer.reportsView.addDynamicNode(analyzer, existingStatusNode); + analyzerStatuses.push(existingStatusNode); + } else { + existingStatusNode.iconPath = new ThemeIcon(iconname); + } + } + } + const statusNode = SidebarContainer.reportsView.getNodeById('statusItem'); + if (!statusNode) { + return; + } + if (uptodate === 0 && outdated === 0 && missing === 0 && failed === 0) { + statusNode?.setLabelAndIcon('Analysis info is unavailable', + new ThemeIcon('question', new ThemeColor('charts.red'))); + } else if (failed > 0) { + statusNode?.setLabelAndIcon('Analysis failed', + new ThemeIcon('error', new ThemeColor('charts.red'))); + } else if (outdated === 0 && failed === 0) { + statusNode?.setLabelAndIcon('Analysis is up-to-date', + new ThemeIcon('check', new ThemeColor('charts.green'))); + } else { + statusNode?.setLabelAndIcon('Analysis is outdated', + new ThemeIcon('clock', new ThemeColor('charts.red'))); + } + if (analyzerStatuses.length > 0) { + statusNode.setChildren(analyzerStatuses); + statusNode.collapsibleState = TreeItemCollapsibleState.Collapsed; + } + SidebarContainer.reportsView.refreshNode(); + statusNode.collapse(); + break; + default: + break; + } + }); + + ExtensionApi.executorManager.addToQueue(process, 'replace'); + } + public async runLogCustomCommand(buildCommand?: string) { if (buildCommand === undefined) { const executorConfig = workspace.getConfiguration('codechecker.executor'); diff --git a/src/backend/executor/process.ts b/src/backend/executor/process.ts index 05d35b6..8797e73 100644 --- a/src/backend/executor/process.ts +++ b/src/backend/executor/process.ts @@ -26,6 +26,7 @@ export enum ProcessType { checkers = 'CodeChecker checkers', log = 'CodeChecker log', parse = 'CodeChecker parse', + status = 'CodeChecker parse --status', version = 'CodeChecker analyzer-version', other = 'Other process', } diff --git a/src/editor/notifications.ts b/src/editor/notifications.ts index 2c7dbb5..b2a4886 100644 --- a/src/editor/notifications.ts +++ b/src/editor/notifications.ts @@ -168,6 +168,7 @@ export class NotificationHandler { }); this.activeNotifications.delete(process.commandLine); + SidebarContainer.reportsView.updateStatus(); break; } case ProcessStatusType.warning: { diff --git a/src/sidebar/views/reports.ts b/src/sidebar/views/reports.ts index 70ac9a5..de163cf 100644 --- a/src/sidebar/views/reports.ts +++ b/src/sidebar/views/reports.ts @@ -18,26 +18,41 @@ import { } from 'vscode'; import { ExtensionApi } from '../../backend'; import { DiagnosticReport } from '../../backend/types'; +import { SidebarContainer } from '../sidebar_container'; export class ReportTreeItem extends TreeItem { parent: ReportTreeItem | undefined; constructor( public readonly _id: string, - public readonly label: string | TreeItemLabel, - public readonly iconPath: ThemeIcon, - public readonly children?: ReportTreeItem[] | undefined + label: string | TreeItemLabel, + iconPath: ThemeIcon, + public children?: ReportTreeItem[] ) { - super(label, children?.length ? TreeItemCollapsibleState.Collapsed : TreeItemCollapsibleState.None); + super(label, (children?.length) ? + TreeItemCollapsibleState.Collapsed : TreeItemCollapsibleState.None); this._id = _id; this.label = label; - this.iconPath = this.iconPath; + this.iconPath = iconPath; this.children = children; // Set parent for children automatically. this.children?.forEach(c => c.parent = this); } + setLabelAndIcon(label?: string, iconPath?: ThemeIcon) { + if (label) { + this.label = label; + } + if (iconPath) { + this.iconPath = iconPath; + } + } + + setChildren(children: ReportTreeItem[] | undefined) { + this.children = children; + } + // This function can be used to set ID attribute of a tree item and all the children of it based on the parent id. setId() { this.id = `${this.parent?.id ?? 'root'}_${this._id}`; @@ -55,6 +70,12 @@ export class ReportTreeItem extends TreeItem { } } + collapse() { + if (this.collapsibleState === TreeItemCollapsibleState.Expanded) { + this.collapsibleState = TreeItemCollapsibleState.Collapsed; + } + } + traverse(cb: (item: ReportTreeItem) => void) { cb(this); this.children?.forEach(c => c.traverse(cb)); @@ -84,16 +105,26 @@ const severityOrder: { [key: string]: number } = { export class ReportsView implements TreeDataProvider { protected currentFile?: Uri; + protected isDirty: boolean = false; protected currentEntryList?: DiagnosticReport[]; protected tree?: TreeView; // Contains [fullpath => item] entries private treeItems: Map = new Map(); private selectedTreeItems: ReportTreeItem[] = []; + private dynamicTreeItems: Map = new Map(); + private rootItems: ReportTreeItem[] = []; constructor(ctx: ExtensionContext) { ctx.subscriptions.push(this._onDidChangeTreeData = new EventEmitter()); - window.onDidChangeActiveTextEditor(this.refreshBugList, this, ctx.subscriptions); + window.onDidChangeActiveTextEditor(editor => { + // event is called twice. Ignore deactivation of the previous editor. + if (editor === undefined) { + return; + } + // this.refreshBugList(); + this.updateStatus(); + }, this, ctx.subscriptions); ExtensionApi.diagnostics.diagnosticsUpdated(() => { // FIXME: fired twice when a file is opened freshly. @@ -113,6 +144,23 @@ export class ReportsView implements TreeDataProvider { )); this.init(); + this.updateStatus(); + } + + public addDynamicNode(id: string, node: ReportTreeItem) { + this.dynamicTreeItems.set(id, node); + } + + public getNodeById(id: string): ReportTreeItem | undefined { + return this.dynamicTreeItems.get(id); + } + + public getAllNodes(): Map { + return this.treeItems; + } + + public refreshNode() { + this._onDidChangeTreeData.fire(); } protected init() { @@ -121,6 +169,12 @@ export class ReportsView implements TreeDataProvider { this.tree?.onDidChangeSelection((item: TreeViewSelectionChangeEvent) => { this.selectedTreeItems = item.selection; }); + + workspace.onDidChangeTextDocument(event => { + if (event?.document === window.activeTextEditor?.document) { + this.updateStatus(); + } + }); } private _onDidChangeTreeData: EventEmitter; @@ -159,6 +213,24 @@ export class ReportsView implements TreeDataProvider { this._onDidChangeTreeData.fire(); } + updateStatus() { + if (window?.activeTextEditor?.document?.isDirty) { + const statusNode = SidebarContainer.reportsView.getNodeById('statusItem'); + if (statusNode) { + statusNode?.setLabelAndIcon('Outdated (file is modified in the editor)', new ThemeIcon('edit')); + if (statusNode.children) { + statusNode.children.forEach(child => { + child.setLabelAndIcon(undefined, new ThemeIcon('edit')); + }); + } + } + } else { + const executorBridge = ExtensionApi.executorBridge; + executorBridge.getFileAnalysisStatus(); + } + this._onDidChangeTreeData.fire(); + } + revealSelectedItems() { const selectedIds = new Set(this.selectedTreeItems.map(item => item.id)); this.treeItems.forEach(root => root.traverse(item => { @@ -301,7 +373,10 @@ export class ReportsView implements TreeDataProvider { // Get root level items. getRootItems(): ReportTreeItem[] | undefined { if (!this.currentEntryList?.length) { - return [new ReportTreeItem('noReportsFound', 'No reports found', new ThemeIcon('pass'))]; + const statusNode = SidebarContainer.reportsView.getNodeById('statusItem'); + statusNode?.setLabelAndIcon('Not in compilation database', + new ThemeIcon('question', new ThemeColor('charts.orange'))); + return statusNode ? [ statusNode ] : undefined; } const severityItems: { [key: string]: TreeDiagnosticReport[] } = {}; @@ -315,6 +390,13 @@ export class ReportsView implements TreeDataProvider { const rootItems: ReportTreeItem[] = []; + let status = SidebarContainer.reportsView.getNodeById('statusItem'); + if (!status) { + status = new ReportTreeItem('statusItem', 'Status', new ThemeIcon('warning')); + SidebarContainer.reportsView.addDynamicNode('statusItem', status); + } + rootItems.push(status); + rootItems.push(...Object.entries(severityItems) .sort(([severityA]: [string, TreeDiagnosticReport[]], [severityB]: [string, TreeDiagnosticReport[]]) => severityOrder[severityA] - severityOrder[severityB])