mirror of
https://github.com/compiler-explorer/compiler-explorer.git
synced 2025-12-27 10:33:59 -05:00
- Add comprehensive test suite covering all Claude Explain functionality: - Basic pane opening and consent flow - no-ai directive detection - API interactions and error handling - Options/customization features - Caching behavior and persistence - Compilation state handling - State persistence across page loads - Fix caching bug in explain-view.ts: - Cache was incorrectly implemented as instance variable, losing cached explanations when panes were closed/reopened - Made cache static to persist across all pane instances (matches consent persistence pattern) - Fixes failing "Caching and reload" Cypress test - Aligns implementation with documented behavior: "shared across all explain views in the session" - Add test utilities and helpers: - Monaco editor content manipulation using clipboard events - Claude Explain specific helpers moved to test file - General utilities remain in utils.ts - Performance optimizations: - Clear Cypress intercepts in afterEach to prevent O(n²) degradation - Use :visible selectors to avoid GoldenLayout template elements - Proper mock setup timing to prevent race conditions - Add comprehensive README with lessons learned and best practices All tests use fake test data (test_first, focus_a, etc.) to clearly distinguish from production values and prevent accidental API calls.
551 lines
20 KiB
TypeScript
551 lines
20 KiB
TypeScript
// Copyright (c) 2025, Compiler Explorer Authors
|
|
// All rights reserved.
|
|
//
|
|
// Redistribution and use in source and binary forms, with or without
|
|
// modification, are permitted provided that the following conditions are met:
|
|
//
|
|
// * Redistributions of source code must retain the above copyright notice,
|
|
// this list of conditions and the following disclaimer.
|
|
// * Redistributions in binary form must reproduce the above copyright
|
|
// notice, this list of conditions and the following disclaimer in the
|
|
// documentation and/or other materials provided with the distribution.
|
|
//
|
|
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
// POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
import {Container} from 'golden-layout';
|
|
import $ from 'jquery';
|
|
import {LRUCache} from 'lru-cache';
|
|
import {capitaliseFirst} from '../../shared/common-utils.js';
|
|
import {CompilationResult} from '../../types/compilation/compilation.interfaces.js';
|
|
import {CompilerInfo} from '../../types/compiler.interfaces.js';
|
|
import {initPopover} from '../bootstrap-utils.js';
|
|
import {Hub} from '../hub.js';
|
|
import {options} from '../options.js';
|
|
import {SentryCapture} from '../sentry.js';
|
|
import * as utils from '../utils.js';
|
|
import {FontScale} from '../widgets/fontscale.js';
|
|
import {AvailableOptions, ClaudeExplainResponse, ExplainRequest, ExplainViewState} from './explain-view.interfaces.js';
|
|
import {
|
|
buildExplainRequest,
|
|
checkForNoAiDirective,
|
|
createPopoverContent,
|
|
ExplainContext,
|
|
formatErrorMessage,
|
|
formatMarkdown,
|
|
formatStatsText,
|
|
generateCacheKey,
|
|
ValidationErrorCode,
|
|
validateExplainPreconditions,
|
|
} from './explain-view-utils.js';
|
|
import {Pane} from './pane.js';
|
|
|
|
enum StatusIconState {
|
|
Loading = 'loading',
|
|
Success = 'success',
|
|
Error = 'error',
|
|
Hidden = 'hidden',
|
|
}
|
|
|
|
const defaultAudienceType = 'beginner';
|
|
const defaultExplanationType = 'assembly';
|
|
|
|
const statusIconConfigs = {
|
|
[StatusIconState.Loading]: {
|
|
classes: 'status-icon fas fa-spinner fa-spin',
|
|
color: '',
|
|
ariaLabel: 'Generating explanation...',
|
|
},
|
|
[StatusIconState.Success]: {
|
|
classes: 'status-icon fas fa-check-circle',
|
|
color: '#4CAF50',
|
|
ariaLabel: 'Explanation generated successfully',
|
|
},
|
|
[StatusIconState.Error]: {
|
|
classes: 'status-icon fas fa-times-circle',
|
|
color: '#FF6645',
|
|
ariaLabel: 'Error generating explanation',
|
|
},
|
|
[StatusIconState.Hidden]: {
|
|
classes: 'status-icon fas d-none',
|
|
color: '',
|
|
ariaLabel: '',
|
|
},
|
|
} as const;
|
|
|
|
export class ExplainView extends Pane<ExplainViewState> {
|
|
private lastResult: CompilationResult | null = null;
|
|
private compiler: CompilerInfo | null = null;
|
|
private readonly statusIcon: JQuery;
|
|
private readonly consentElement: JQuery;
|
|
private readonly noAiElement: JQuery;
|
|
private readonly contentElement: JQuery;
|
|
private readonly bottomBarElement: JQuery;
|
|
private readonly statsElement: JQuery;
|
|
private readonly audienceSelect: JQuery;
|
|
private readonly explanationSelect: JQuery;
|
|
private readonly audienceInfoButton: JQuery;
|
|
private readonly explanationInfoButton: JQuery;
|
|
private readonly explainApiEndpoint: string;
|
|
private readonly fontScale: FontScale;
|
|
// Use a static variable to persist consent across all instances during the session
|
|
private static consentGiven = false;
|
|
|
|
// Static cache for available options (shared across all instances)
|
|
private static availableOptions: AvailableOptions | null = null;
|
|
private static optionsFetchPromise: Promise<AvailableOptions> | null = null;
|
|
|
|
// Static explanation cache shared across all instances (200KB limit)
|
|
private static cache: LRUCache<string, ClaudeExplainResponse> | null = null;
|
|
|
|
// Instance variables for selected options
|
|
private selectedAudience: string;
|
|
private selectedExplanation: string;
|
|
private isInitializing = true;
|
|
|
|
// Store compilation results that arrive before initialization completes
|
|
private pendingCompilationResult: CompilationResult | null = null;
|
|
|
|
constructor(hub: Hub, container: Container, state: ExplainViewState) {
|
|
super(hub, container, state);
|
|
|
|
this.explainApiEndpoint = options.explainApiEndpoint ?? '';
|
|
|
|
// Initialize static cache if not already done
|
|
if (!ExplainView.cache) {
|
|
ExplainView.cache = new LRUCache({
|
|
maxSize: 200 * 1024,
|
|
sizeCalculation: n => JSON.stringify(n).length,
|
|
});
|
|
}
|
|
|
|
this.statusIcon = this.domRoot.find('.status-icon');
|
|
this.consentElement = this.domRoot.find('.explain-consent');
|
|
this.noAiElement = this.domRoot.find('.explain-no-ai');
|
|
this.contentElement = this.domRoot.find('.explain-content');
|
|
this.bottomBarElement = this.domRoot.find('.explain-bottom-bar');
|
|
this.statsElement = this.domRoot.find('.explain-stats');
|
|
this.audienceSelect = this.domRoot.find('.explain-audience');
|
|
this.explanationSelect = this.domRoot.find('.explain-type');
|
|
this.audienceInfoButton = this.domRoot.find('.explain-audience-info');
|
|
this.explanationInfoButton = this.domRoot.find('.explain-type-info');
|
|
|
|
this.fontScale = new FontScale(this.domRoot, state, '.explain-content');
|
|
this.fontScale.on('change', this.updateState.bind(this));
|
|
|
|
this.attachEventListeners();
|
|
void this.initializeOptions();
|
|
|
|
this.contentElement.text('Waiting for compilation...');
|
|
this.isAwaitingInitialResults = true;
|
|
this.eventHub.emit('explainViewOpened', this.compilerInfo.compilerId);
|
|
}
|
|
|
|
private handleConsentClick(): void {
|
|
ExplainView.consentGiven = true;
|
|
this.consentElement.addClass('d-none');
|
|
void this.fetchExplanation();
|
|
}
|
|
|
|
private handleReloadClick(): void {
|
|
void this.fetchExplanation(true);
|
|
}
|
|
|
|
private handleAudienceChange(): void {
|
|
this.selectedAudience = this.audienceSelect.val() as string;
|
|
this.updateState();
|
|
this.refreshExplanationIfReady();
|
|
}
|
|
|
|
private handleExplanationChange(): void {
|
|
this.selectedExplanation = this.explanationSelect.val() as string;
|
|
this.updateState();
|
|
this.refreshExplanationIfReady();
|
|
}
|
|
|
|
private refreshExplanationIfReady(): void {
|
|
if (ExplainView.consentGiven && this.lastResult) {
|
|
void this.fetchExplanation();
|
|
}
|
|
}
|
|
|
|
private attachEventListeners(): void {
|
|
this.consentElement.find('.consent-btn').on('click', this.handleConsentClick.bind(this));
|
|
this.bottomBarElement.find('.explain-reload').on('click', this.handleReloadClick.bind(this));
|
|
this.audienceSelect.on('change', this.handleAudienceChange.bind(this));
|
|
this.explanationSelect.on('change', this.handleExplanationChange.bind(this));
|
|
}
|
|
|
|
override getInitialHTML(): string {
|
|
return $('#explain').html();
|
|
}
|
|
|
|
private async initializeOptions(): Promise<void> {
|
|
try {
|
|
this.populateSelectOptions(await this.fetchAvailableOptions());
|
|
this.isInitializing = false;
|
|
|
|
// Process any compilation results that arrived while we were initializing.
|
|
if (this.pendingCompilationResult) {
|
|
this.handleCompilationResult(this.pendingCompilationResult);
|
|
this.pendingCompilationResult = null;
|
|
}
|
|
} catch (error) {
|
|
this.isInitializing = false;
|
|
console.error('Failed to initialize options:', error);
|
|
this.showExplainUnavailable();
|
|
// Even if initialization failed, clear any pending results
|
|
this.pendingCompilationResult = null;
|
|
}
|
|
}
|
|
|
|
private showExplainUnavailable(): void {
|
|
const emptyOptions = [{value: '', description: 'Service unavailable'}];
|
|
this.populateSelect(this.audienceSelect, emptyOptions);
|
|
this.populateSelect(this.explanationSelect, emptyOptions);
|
|
|
|
this.audienceSelect.prop('disabled', true);
|
|
this.explanationSelect.prop('disabled', true);
|
|
|
|
this.contentElement.html(
|
|
'<div class="alert alert-warning">Claude Explain is currently unavailable due to a service error. Please try again later.</div>',
|
|
);
|
|
}
|
|
|
|
private populateSelect(selectElement: JQuery, optionsList: Array<{value: string; description: string}>): void {
|
|
selectElement.empty();
|
|
optionsList.forEach(option => {
|
|
const optionElement = $('<option></option>')
|
|
.attr('value', option.value)
|
|
.text(capitaliseFirst(option.value))
|
|
.attr('title', option.description);
|
|
selectElement.append(optionElement);
|
|
});
|
|
}
|
|
|
|
private populateSelectOptions(options: AvailableOptions): void {
|
|
this.populateSelect(this.audienceSelect, options.audience);
|
|
this.populateSelect(this.explanationSelect, options.explanation);
|
|
this.updatePopoverContent(options);
|
|
|
|
if (this.isInitializing) {
|
|
// During initialisation: trust saved state completely, no validation
|
|
this.audienceSelect.val(this.selectedAudience);
|
|
this.explanationSelect.val(this.selectedExplanation);
|
|
} else {
|
|
// After initialisation: validate user changes normally
|
|
const validAudienceValue = options.audience.some(opt => opt.value === this.selectedAudience)
|
|
? this.selectedAudience
|
|
: defaultAudienceType;
|
|
const validExplanationValue = options.explanation.some(opt => opt.value === this.selectedExplanation)
|
|
? this.selectedExplanation
|
|
: defaultExplanationType;
|
|
|
|
this.selectedAudience = validAudienceValue;
|
|
this.selectedExplanation = validExplanationValue;
|
|
|
|
this.audienceSelect.val(validAudienceValue);
|
|
this.explanationSelect.val(validExplanationValue);
|
|
}
|
|
}
|
|
|
|
private updatePopoverContent(options: AvailableOptions): void {
|
|
const audienceContent = createPopoverContent(options.audience);
|
|
const explanationContent = createPopoverContent(options.explanation);
|
|
|
|
initPopover(this.audienceInfoButton, {
|
|
content: audienceContent,
|
|
html: true,
|
|
placement: 'bottom',
|
|
trigger: 'focus',
|
|
});
|
|
initPopover(this.explanationInfoButton, {
|
|
content: explanationContent,
|
|
html: true,
|
|
placement: 'bottom',
|
|
trigger: 'focus',
|
|
});
|
|
}
|
|
|
|
private async fetchAvailableOptions(): Promise<AvailableOptions> {
|
|
// If we already have options cached, return them
|
|
if (ExplainView.availableOptions) return ExplainView.availableOptions;
|
|
// If we're already fetching, wait for that promise
|
|
if (ExplainView.optionsFetchPromise) return ExplainView.optionsFetchPromise;
|
|
|
|
// Else, go fetch the options
|
|
ExplainView.optionsFetchPromise = (async () => {
|
|
try {
|
|
const response = await fetch(this.explainApiEndpoint, {
|
|
method: 'GET',
|
|
headers: {'Content-Type': 'application/json'},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch options: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const options = (await response.json()) as AvailableOptions;
|
|
ExplainView.availableOptions = options;
|
|
return options;
|
|
} finally {
|
|
ExplainView.optionsFetchPromise = null;
|
|
}
|
|
})();
|
|
|
|
return ExplainView.optionsFetchPromise;
|
|
}
|
|
|
|
override initializeStateDependentProperties(state: ExplainViewState): void {
|
|
this.selectedAudience = state.audience ?? defaultAudienceType;
|
|
this.selectedExplanation = state.explanation ?? defaultExplanationType;
|
|
}
|
|
|
|
override getCurrentState(): ExplainViewState {
|
|
const state = super.getCurrentState() as ExplainViewState;
|
|
state.audience = this.selectedAudience;
|
|
state.explanation = this.selectedExplanation;
|
|
return state;
|
|
}
|
|
|
|
override onCompiler(
|
|
compilerId: number,
|
|
compiler: CompilerInfo | null,
|
|
_compilerOptions: string,
|
|
editorId: number,
|
|
treeId: number,
|
|
): void {
|
|
if (this.compilerInfo.compilerId !== compilerId) return;
|
|
this.compilerInfo.compilerName = compiler ? compiler.name : '';
|
|
this.compilerInfo.editorId = editorId;
|
|
this.compilerInfo.treeId = treeId;
|
|
this.compiler = compiler;
|
|
this.updateTitle();
|
|
}
|
|
|
|
private hideSpecialUIElements(): void {
|
|
this.consentElement.addClass('d-none');
|
|
this.noAiElement.addClass('d-none');
|
|
}
|
|
|
|
private handleCompilationResult(result: CompilationResult): void {
|
|
if (this.isInitializing) {
|
|
// Store for processing after initialization completes
|
|
this.pendingCompilationResult = result;
|
|
return;
|
|
}
|
|
|
|
if (result.code !== 0) {
|
|
this.contentElement.text('Cannot explain: Compilation failed');
|
|
return;
|
|
}
|
|
|
|
if (result.source && checkForNoAiDirective(result.source)) {
|
|
this.showNoAiDirective();
|
|
return;
|
|
}
|
|
|
|
if (ExplainView.consentGiven) {
|
|
void this.fetchExplanation();
|
|
} else {
|
|
this.showConsentUI();
|
|
}
|
|
}
|
|
|
|
private showNoAiDirective(): void {
|
|
this.noAiElement.removeClass('d-none');
|
|
this.contentElement.text('');
|
|
}
|
|
|
|
private showConsentUI(): void {
|
|
this.consentElement.removeClass('d-none');
|
|
this.contentElement.text('Claude needs your consent to explain this code.');
|
|
}
|
|
|
|
override onCompileResult(id: number, compiler: CompilerInfo, result: CompilationResult): void {
|
|
if (id !== this.compilerInfo.compilerId) return;
|
|
|
|
this.compiler = compiler;
|
|
this.lastResult = result;
|
|
this.isAwaitingInitialResults = false;
|
|
|
|
this.hideSpecialUIElements();
|
|
this.handleCompilationResult(result);
|
|
}
|
|
|
|
private setStatusIcon(state: StatusIconState): void {
|
|
const config = statusIconConfigs[state];
|
|
this.statusIcon
|
|
.removeClass()
|
|
.addClass(config.classes)
|
|
.css('color', config.color)
|
|
.attr('aria-label', config.ariaLabel);
|
|
}
|
|
|
|
private showLoading(): void {
|
|
this.setStatusIcon(StatusIconState.Loading);
|
|
this.contentElement.text('Generating explanation...');
|
|
}
|
|
|
|
private hideLoading(): void {
|
|
this.setStatusIcon(StatusIconState.Hidden);
|
|
}
|
|
|
|
private showSuccess(): void {
|
|
this.setStatusIcon(StatusIconState.Success);
|
|
}
|
|
|
|
private showError(): void {
|
|
this.setStatusIcon(StatusIconState.Error);
|
|
}
|
|
|
|
private showBottomBar(): void {
|
|
this.bottomBarElement.removeClass('d-none');
|
|
}
|
|
|
|
private updateStatsInBottomBar(
|
|
data: ClaudeExplainResponse,
|
|
clientCacheHit: boolean,
|
|
serverCacheHit: boolean,
|
|
): void {
|
|
const stats = formatStatsText(data, clientCacheHit, serverCacheHit);
|
|
if (stats.length > 0) {
|
|
this.statsElement.text(stats.join(' | '));
|
|
}
|
|
}
|
|
|
|
private displayCachedResult(cachedResult: ClaudeExplainResponse): void {
|
|
this.hideLoading();
|
|
this.showSuccess();
|
|
this.renderMarkdown(cachedResult.explanation);
|
|
this.showBottomBar();
|
|
|
|
if (cachedResult.usage) {
|
|
this.updateStatsInBottomBar(cachedResult, true, false);
|
|
}
|
|
}
|
|
|
|
private async fetchFromAPI(payload: ExplainRequest): Promise<ClaudeExplainResponse> {
|
|
const response = await window.fetch(this.explainApiEndpoint, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Server returned ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
return response.json() as Promise<ClaudeExplainResponse>;
|
|
}
|
|
|
|
private getExplainContext(): ExplainContext {
|
|
return {
|
|
lastResult: this.lastResult,
|
|
compiler: this.compiler,
|
|
selectedAudience: this.selectedAudience,
|
|
selectedExplanation: this.selectedExplanation,
|
|
explainApiEndpoint: this.explainApiEndpoint,
|
|
consentGiven: ExplainView.consentGiven,
|
|
availableOptions: ExplainView.availableOptions,
|
|
};
|
|
}
|
|
|
|
private processExplanationResponse(data: ClaudeExplainResponse, cacheKey: string): void {
|
|
this.hideLoading();
|
|
|
|
if (data.status === 'error') {
|
|
this.showError();
|
|
this.contentElement.text(`Error: ${data.message || 'Unknown error'}`);
|
|
return;
|
|
}
|
|
|
|
this.showSuccess();
|
|
ExplainView.cache!.set(cacheKey, data);
|
|
this.renderMarkdown(data.explanation);
|
|
this.showBottomBar();
|
|
|
|
if (data.usage) {
|
|
this.updateStatsInBottomBar(data, false, data.cached);
|
|
}
|
|
}
|
|
|
|
private async fetchExplanation(bypassCache = false): Promise<void> {
|
|
const context = this.getExplainContext();
|
|
const validationResult = validateExplainPreconditions(context);
|
|
|
|
if (!validationResult.success) {
|
|
switch (validationResult.errorCode) {
|
|
case ValidationErrorCode.NO_AI_DIRECTIVE_FOUND:
|
|
this.hideLoading();
|
|
this.noAiElement.removeClass('d-none');
|
|
this.contentElement.text('');
|
|
break;
|
|
case ValidationErrorCode.MISSING_REQUIRED_DATA:
|
|
// Silent return - this is expected during normal UI flow (before compilation, before consent, etc.)
|
|
break;
|
|
default:
|
|
// Show all other validation errors to help with debugging
|
|
this.contentElement.text(`Error: ${validationResult.message}`);
|
|
break;
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.contentElement.empty();
|
|
this.showLoading();
|
|
|
|
try {
|
|
const payload = buildExplainRequest(context, bypassCache);
|
|
const cacheKey = generateCacheKey(payload);
|
|
|
|
// Check cache first unless bypassing
|
|
if (!bypassCache) {
|
|
const cachedResult = ExplainView.cache!.get(cacheKey);
|
|
if (cachedResult) {
|
|
this.displayCachedResult(cachedResult);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const data = await this.fetchFromAPI(payload);
|
|
this.processExplanationResponse(data, cacheKey);
|
|
} catch (error) {
|
|
this.handleFetchError(error);
|
|
}
|
|
}
|
|
|
|
private handleFetchError(error: unknown): void {
|
|
this.hideLoading();
|
|
this.showError();
|
|
this.contentElement.text(formatErrorMessage(error));
|
|
SentryCapture(error);
|
|
}
|
|
|
|
private renderMarkdown(markdown: string): void {
|
|
this.contentElement.html(formatMarkdown(markdown));
|
|
}
|
|
|
|
override resize(): void {
|
|
utils.updateAndCalcTopBarHeight(this.domRoot, this.topBar, this.hideable);
|
|
}
|
|
|
|
override getDefaultPaneName(): string {
|
|
return 'Claude Explain';
|
|
}
|
|
|
|
override close(): void {
|
|
this.eventHub.emit('explainViewClosed', this.compilerInfo.compilerId);
|
|
this.eventHub.unsubscribe();
|
|
}
|
|
}
|