647 lines
24 KiB
JavaScript
647 lines
24 KiB
JavaScript
/**
|
||
* GitHub Code Viewer - Main JavaScript
|
||
*/
|
||
|
||
(function($) {
|
||
'use strict';
|
||
|
||
// GitHub Code Viewer Class
|
||
class GitHubCodeViewer {
|
||
constructor(element) {
|
||
this.$element = $(element);
|
||
this.repo = this.$element.data('repo');
|
||
this.theme = this.$element.data('theme');
|
||
this.showLineNumbers = this.$element.data('show-line-numbers');
|
||
this.initialFile = this.$element.data('initial-file');
|
||
|
||
this.files = [];
|
||
this.openTabs = [];
|
||
this.activeTab = null;
|
||
this.fileCache = {};
|
||
this.activeRequests = []; // Track active AJAX requests
|
||
this.currentPath = ''; // Track current folder path
|
||
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
this.bindEvents();
|
||
this.loadRepositoryFiles();
|
||
}
|
||
|
||
bindEvents() {
|
||
// Search functionality with debouncing
|
||
let searchTimeout;
|
||
this.$element.on('input', '.mcb-search-input', (e) => {
|
||
clearTimeout(searchTimeout);
|
||
searchTimeout = setTimeout(() => {
|
||
this.filterFiles($(e.target).val());
|
||
}, 300); // Debounce for 300ms
|
||
});
|
||
|
||
// File/folder selection
|
||
this.$element.on('click', '.mcb-file-item', (e) => {
|
||
const $item = $(e.currentTarget);
|
||
const isFolder = $item.data('is-folder');
|
||
const path = $item.data('path');
|
||
|
||
if (isFolder) {
|
||
// Navigate to folder
|
||
this.loadRepositoryFiles(path);
|
||
} else {
|
||
// Load file
|
||
this.loadFile(path);
|
||
}
|
||
});
|
||
|
||
// Tab management
|
||
this.$element.on('click', '.mcb-tab', (e) => {
|
||
const $tab = $(e.currentTarget);
|
||
const filePath = $tab.data('path');
|
||
this.switchToTab(filePath);
|
||
});
|
||
|
||
this.$element.on('click', '.mcb-tab-close', (e) => {
|
||
e.stopPropagation();
|
||
const $tab = $(e.target).closest('.mcb-tab');
|
||
const filePath = $tab.data('path');
|
||
this.closeTab(filePath);
|
||
});
|
||
|
||
// Copy button
|
||
this.$element.on('click', '.mcb-copy-btn', (e) => {
|
||
this.copyCode($(e.target));
|
||
});
|
||
|
||
// Home button - go to root
|
||
this.$element.on('click', '.mcb-home-btn', () => {
|
||
this.loadRepositoryFiles(''); // Load root
|
||
});
|
||
|
||
// Refresh button
|
||
this.$element.on('click', '.mcb-refresh-btn', () => {
|
||
this.refreshFiles();
|
||
});
|
||
|
||
// Fullscreen toggle
|
||
this.$element.on('click', '.mcb-fullscreen-btn', () => {
|
||
this.toggleFullscreen();
|
||
});
|
||
}
|
||
|
||
loadRepositoryFiles(path = '') {
|
||
console.log('MCB: Loading repository files for:', this.repo, 'path:', path);
|
||
const $fileList = this.$element.find('.mcb-file-list');
|
||
|
||
// Update current path
|
||
this.currentPath = path;
|
||
|
||
// Show loading indicator
|
||
$fileList.html('<div class="mcb-loading"><div class="mcb-spinner"></div><span>Loading files...</span></div>');
|
||
|
||
// Abort any pending requests
|
||
this.abortActiveRequests();
|
||
|
||
console.log('MCB: Making AJAX request to:', mcb_ajax.ajax_url);
|
||
const request = $.ajax({
|
||
url: mcb_ajax.ajax_url,
|
||
type: 'POST',
|
||
data: {
|
||
action: 'mcb_get_repo_files',
|
||
repo: this.repo,
|
||
path: path,
|
||
nonce: mcb_ajax.nonce
|
||
},
|
||
success: (response) => {
|
||
console.log('MCB: AJAX response:', response);
|
||
this.removeRequest(request);
|
||
if (response.success) {
|
||
console.log('MCB: Files loaded:', response.data);
|
||
this.files = response.data;
|
||
this.renderFileList();
|
||
|
||
// Load initial file if specified
|
||
if (this.initialFile) {
|
||
const file = this.files.find(f => f.path === this.initialFile);
|
||
if (file) {
|
||
this.loadFile(this.initialFile);
|
||
}
|
||
}
|
||
} else {
|
||
console.error('MCB: Error:', response.data);
|
||
$fileList.html('<div class="mcb-error">' + response.data + '</div>');
|
||
}
|
||
},
|
||
error: (xhr, status, error) => {
|
||
console.error('MCB: AJAX failed:', status, error);
|
||
this.removeRequest(request);
|
||
$fileList.html('<div class="mcb-error">Failed to load repository files: ' + error + '</div>');
|
||
}
|
||
});
|
||
|
||
this.activeRequests.push(request);
|
||
}
|
||
|
||
renderFileList() {
|
||
const $fileList = this.$element.find('.mcb-file-list');
|
||
$fileList.empty();
|
||
|
||
// Add current path breadcrumb if not at root
|
||
if (this.currentPath) {
|
||
const $breadcrumb = $('<div class="mcb-breadcrumb">');
|
||
$breadcrumb.append('<span class="mcb-path-label">Path: </span>');
|
||
$breadcrumb.append('<span class="mcb-current-path">/' + this.escapeHtml(this.currentPath) + '</span>');
|
||
$fileList.append($breadcrumb);
|
||
}
|
||
|
||
// Render files and folders
|
||
this.files.forEach(file => {
|
||
const $item = $('<div class="mcb-file-item">')
|
||
.attr('data-path', file.path)
|
||
.attr('data-name', file.name.toLowerCase())
|
||
.attr('data-is-folder', file.is_folder || false);
|
||
|
||
// Add appropriate icon
|
||
let icon;
|
||
if (file.type === 'parent') {
|
||
icon = '⬆'; // Up arrow for parent
|
||
$item.addClass('mcb-parent-folder');
|
||
} else if (file.is_folder) {
|
||
icon = '📁'; // Folder icon
|
||
$item.addClass('mcb-folder');
|
||
} else {
|
||
icon = this.getFileIcon(file.type);
|
||
$item.addClass('mcb-file');
|
||
}
|
||
|
||
$item.append('<span class="mcb-file-icon">' + icon + '</span>');
|
||
$item.append('<span class="mcb-file-name">' + this.escapeHtml(file.name) + '</span>');
|
||
|
||
// Add file size for files only
|
||
if (!file.is_folder) {
|
||
const sizeStr = this.formatFileSize(file.size);
|
||
$item.append('<span class="mcb-file-size">' + sizeStr + '</span>');
|
||
}
|
||
|
||
$fileList.append($item);
|
||
});
|
||
}
|
||
|
||
organizeFileTree(files) {
|
||
const tree = {};
|
||
files.forEach(file => {
|
||
const parts = file.path.split('/');
|
||
let current = tree;
|
||
|
||
parts.forEach((part, index) => {
|
||
if (index === parts.length - 1) {
|
||
// It's a file
|
||
current[part] = file;
|
||
} else {
|
||
// It's a directory
|
||
if (!current[part]) {
|
||
current[part] = {};
|
||
}
|
||
current = current[part];
|
||
}
|
||
});
|
||
});
|
||
return tree;
|
||
}
|
||
|
||
filterFiles(searchTerm) {
|
||
const $items = this.$element.find('.mcb-file-item');
|
||
const term = searchTerm.toLowerCase();
|
||
|
||
$items.each((index, item) => {
|
||
const $item = $(item);
|
||
const fileName = $item.data('name');
|
||
|
||
if (!term || fileName.includes(term)) {
|
||
$item.show();
|
||
} else {
|
||
$item.hide();
|
||
}
|
||
});
|
||
}
|
||
|
||
loadFile(filePath) {
|
||
// Limit cache size to prevent memory issues
|
||
this.limitCacheSize();
|
||
|
||
// Check if file is already in cache
|
||
if (this.fileCache[filePath]) {
|
||
this.displayFile(filePath, this.fileCache[filePath]);
|
||
return;
|
||
}
|
||
|
||
// Update status
|
||
this.updateStatus('Loading file...');
|
||
|
||
const request = $.ajax({
|
||
url: mcb_ajax.ajax_url,
|
||
type: 'POST',
|
||
data: {
|
||
action: 'mcb_load_file',
|
||
repo: this.repo,
|
||
file_path: filePath,
|
||
nonce: mcb_ajax.nonce
|
||
},
|
||
success: (response) => {
|
||
this.removeRequest(request);
|
||
if (response.success) {
|
||
this.fileCache[filePath] = response.data;
|
||
this.displayFile(filePath, response.data);
|
||
this.updateStatus('Ready');
|
||
} else {
|
||
this.showError('Failed to load file: ' + response.data);
|
||
this.updateStatus('Error loading file');
|
||
}
|
||
},
|
||
error: () => {
|
||
this.removeRequest(request);
|
||
this.showError('Network error while loading file');
|
||
this.updateStatus('Network error');
|
||
}
|
||
});
|
||
|
||
this.activeRequests.push(request);
|
||
}
|
||
|
||
displayFile(filePath, fileData) {
|
||
// Store in cache
|
||
this.fileCache[filePath] = fileData;
|
||
|
||
// Limit number of open tabs to prevent memory issues
|
||
const maxTabs = 10;
|
||
|
||
// Add to tabs if not already open
|
||
if (!this.openTabs.includes(filePath)) {
|
||
// Check tab limit
|
||
if (this.openTabs.length >= maxTabs) {
|
||
// Close the oldest tab
|
||
const oldestTab = this.openTabs[0];
|
||
this.closeTab(oldestTab);
|
||
}
|
||
|
||
this.openTabs.push(filePath);
|
||
this.addTab(filePath, fileData.filename);
|
||
}
|
||
|
||
// Only switch to tab if not already active
|
||
if (this.activeTab !== filePath) {
|
||
this.switchToTab(filePath);
|
||
} else {
|
||
// Just update the content if already active
|
||
const $codeArea = this.$element.find('.mcb-code-area');
|
||
const safeContent = this.createSafeCodeDisplay(fileData.content, fileData.filename);
|
||
$codeArea.html(safeContent);
|
||
|
||
// Apply syntax highlighting
|
||
if (typeof Prism !== 'undefined') {
|
||
Prism.highlightAll();
|
||
}
|
||
|
||
// Update status
|
||
const fileSize = this.formatFileSize(fileData.content.length);
|
||
const lineCount = fileData.content.split('\n').length;
|
||
this.$element.find('.mcb-file-info').text(fileData.filename + ' • ' + lineCount + ' lines • ' + fileSize);
|
||
}
|
||
|
||
// Display content
|
||
const $codeArea = this.$element.find('.mcb-code-area');
|
||
|
||
// Create safe HTML content
|
||
const safeContent = this.createSafeCodeDisplay(fileData.content, fileData.filename);
|
||
$codeArea.html(safeContent);
|
||
|
||
// Apply syntax highlighting if Prism is loaded
|
||
if (typeof Prism !== 'undefined') {
|
||
Prism.highlightAll();
|
||
}
|
||
|
||
// Mark file as active in sidebar
|
||
this.$element.find('.mcb-file-item').removeClass('active');
|
||
this.$element.find('.mcb-file-item[data-path="' + filePath + '"]').addClass('active');
|
||
|
||
// Update file info
|
||
const file = this.files.find(f => f.path === filePath);
|
||
if (file) {
|
||
this.updateFileInfo(file);
|
||
}
|
||
}
|
||
|
||
createSafeCodeDisplay(content, filename) {
|
||
// The content is already escaped by PHP, but we'll double-check
|
||
const $container = $('<div class="mcb-code-container">');
|
||
|
||
// Header
|
||
const $header = $('<div class="mcb-code-header">');
|
||
$header.append($('<span class="mcb-filename">').text(filename));
|
||
|
||
// Copy button - store content in data, not attribute
|
||
const $copyBtn = $('<button class="mcb-copy-btn">Copy</button>');
|
||
$copyBtn.data('content', content); // Use data() instead of attr()
|
||
$header.append($copyBtn);
|
||
|
||
$container.append($header);
|
||
|
||
// Code wrapper
|
||
const $wrapper = $('<div class="mcb-code-wrapper">');
|
||
const language = this.detectLanguage(filename);
|
||
|
||
// Create pre/code structure
|
||
const $pre = $('<pre>').addClass('line-numbers');
|
||
const $code = $('<code>').addClass('language-' + language);
|
||
|
||
// CRITICAL: Ensure content is text, not HTML
|
||
$code.text(content);
|
||
|
||
$pre.append($code);
|
||
$wrapper.append($pre);
|
||
$container.append($wrapper);
|
||
|
||
return $container;
|
||
}
|
||
|
||
addTab(filePath, filename) {
|
||
const $tabs = this.$element.find('.mcb-tabs');
|
||
|
||
const $tab = $('<div class="mcb-tab">')
|
||
.attr('data-path', filePath);
|
||
|
||
$tab.append('<span class="mcb-tab-title">' + this.escapeHtml(filename) + '</span>');
|
||
$tab.append('<span class="mcb-tab-close">×</span>');
|
||
|
||
$tabs.append($tab);
|
||
}
|
||
|
||
switchToTab(filePath) {
|
||
// Prevent switching if already active
|
||
if (this.activeTab === filePath) {
|
||
return;
|
||
}
|
||
|
||
this.activeTab = filePath;
|
||
|
||
// Update tab states
|
||
this.$element.find('.mcb-tab').removeClass('active');
|
||
this.$element.find('.mcb-tab[data-path="' + filePath + '"]').addClass('active');
|
||
|
||
// Load file content if cached, otherwise just display the tab
|
||
if (this.fileCache[filePath]) {
|
||
// Display cached content without calling displayFile
|
||
const $codeArea = this.$element.find('.mcb-code-area');
|
||
const fileData = this.fileCache[filePath];
|
||
const safeContent = this.createSafeCodeDisplay(fileData.content, fileData.filename);
|
||
$codeArea.html(safeContent);
|
||
|
||
// Apply syntax highlighting if Prism is loaded
|
||
if (typeof Prism !== 'undefined') {
|
||
Prism.highlightAll();
|
||
}
|
||
|
||
// Update status
|
||
const fileSize = this.formatFileSize(fileData.content.length);
|
||
const lineCount = fileData.content.split('\n').length;
|
||
this.$element.find('.mcb-file-info').text(fileData.filename + ' • ' + lineCount + ' lines • ' + fileSize);
|
||
}
|
||
}
|
||
|
||
closeTab(filePath) {
|
||
// Remove from open tabs
|
||
const index = this.openTabs.indexOf(filePath);
|
||
if (index > -1) {
|
||
this.openTabs.splice(index, 1);
|
||
}
|
||
|
||
// Remove tab element
|
||
this.$element.find('.mcb-tab[data-path="' + filePath + '"]').remove();
|
||
|
||
// If this was the active tab, switch to another
|
||
if (this.activeTab === filePath) {
|
||
if (this.openTabs.length > 0) {
|
||
this.switchToTab(this.openTabs[this.openTabs.length - 1]);
|
||
} else {
|
||
// Show welcome screen
|
||
this.showWelcome();
|
||
}
|
||
}
|
||
}
|
||
|
||
copyCode($button) {
|
||
const content = $button.data('content'); // Use data() instead of attr()
|
||
|
||
if (!content) {
|
||
return;
|
||
}
|
||
|
||
// Create temporary textarea
|
||
const $temp = $('<textarea>');
|
||
$temp.val(content);
|
||
$('body').append($temp);
|
||
$temp.select();
|
||
|
||
try {
|
||
document.execCommand('copy');
|
||
$button.text('Copied!').addClass('copied');
|
||
setTimeout(() => {
|
||
$button.text('Copy').removeClass('copied');
|
||
}, 2000);
|
||
} catch (err) {
|
||
console.error('Failed to copy:', err);
|
||
}
|
||
|
||
$temp.remove();
|
||
}
|
||
|
||
refreshFiles() {
|
||
// Abort any pending requests first
|
||
this.abortActiveRequests();
|
||
|
||
// Clear cache
|
||
this.fileCache = {};
|
||
|
||
// Reload files at current path
|
||
this.loadRepositoryFiles(this.currentPath);
|
||
this.updateStatus('Repository refreshed');
|
||
}
|
||
|
||
toggleFullscreen() {
|
||
this.$element.toggleClass('fullscreen');
|
||
}
|
||
|
||
showWelcome() {
|
||
const $codeArea = this.$element.find('.mcb-code-area');
|
||
const welcome = `
|
||
<div class="mcb-welcome">
|
||
<svg viewBox="0 0 24 24" width="64" height="64">
|
||
<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/>
|
||
</svg>
|
||
<h4>GitHub Code Viewer</h4>
|
||
<p>Select a file from the sidebar to view its content</p>
|
||
</div>
|
||
`;
|
||
$codeArea.html(welcome);
|
||
}
|
||
|
||
showError(message) {
|
||
const $codeArea = this.$element.find('.mcb-code-area');
|
||
$codeArea.html('<div class="mcb-error">' + this.escapeHtml(message) + '</div>');
|
||
}
|
||
|
||
updateStatus(text) {
|
||
this.$element.find('.mcb-status-text').text(text);
|
||
}
|
||
|
||
updateFileInfo(file) {
|
||
const info = file.name + ' • ' + this.formatFileSize(file.size) + ' • ' + file.type;
|
||
this.$element.find('.mcb-file-info').text(info);
|
||
}
|
||
|
||
formatFileSize(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||
}
|
||
|
||
detectLanguage(filename) {
|
||
const ext = filename.split('.').pop().toLowerCase();
|
||
const languageMap = {
|
||
'js': 'javascript',
|
||
'jsx': 'jsx',
|
||
'ts': 'typescript',
|
||
'tsx': 'tsx',
|
||
'py': 'python',
|
||
'rb': 'ruby',
|
||
'php': 'php',
|
||
'java': 'java',
|
||
'c': 'c',
|
||
'cpp': 'cpp',
|
||
'cs': 'csharp',
|
||
'go': 'go',
|
||
'rs': 'rust',
|
||
'swift': 'swift',
|
||
'kt': 'kotlin',
|
||
'scala': 'scala',
|
||
'r': 'r',
|
||
'sql': 'sql',
|
||
'sh': 'bash',
|
||
'yml': 'yaml',
|
||
'json': 'json',
|
||
'xml': 'xml',
|
||
'html': 'html',
|
||
'css': 'css',
|
||
'scss': 'scss',
|
||
'md': 'markdown'
|
||
};
|
||
|
||
return languageMap[ext] || 'plain';
|
||
}
|
||
|
||
getFileIcon(type) {
|
||
const icons = {
|
||
'javascript': '📜',
|
||
'python': '🐍',
|
||
'php': '🐘',
|
||
'java': '☕',
|
||
'cpp': '⚙️',
|
||
'go': '🐹',
|
||
'rust': '🦀',
|
||
'ruby': '💎',
|
||
'html': '🌐',
|
||
'css': '🎨',
|
||
'json': '📋',
|
||
'markdown': '📝',
|
||
'default': '📄'
|
||
};
|
||
|
||
return icons[type] || icons.default;
|
||
}
|
||
|
||
escapeHtml(text) {
|
||
const map = {
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
};
|
||
|
||
return text.replace(/[&<>"']/g, m => map[m]);
|
||
}
|
||
|
||
limitCacheSize() {
|
||
// Limit cache to 20 files to prevent memory issues
|
||
const maxCacheSize = 20;
|
||
const cacheKeys = Object.keys(this.fileCache);
|
||
|
||
if (cacheKeys.length >= maxCacheSize) {
|
||
// Remove oldest cached files (FIFO)
|
||
const toRemove = cacheKeys.slice(0, cacheKeys.length - maxCacheSize + 1);
|
||
toRemove.forEach(key => {
|
||
delete this.fileCache[key];
|
||
});
|
||
}
|
||
}
|
||
|
||
cleanupBeforeUnload() {
|
||
// Clean up event listeners and large objects
|
||
this.$element.off();
|
||
this.abortActiveRequests();
|
||
this.fileCache = null;
|
||
this.files = null;
|
||
this.openTabs = null;
|
||
}
|
||
|
||
abortActiveRequests() {
|
||
// Abort all pending AJAX requests
|
||
if (this.activeRequests && this.activeRequests.length > 0) {
|
||
this.activeRequests.forEach(request => {
|
||
if (request && request.abort) {
|
||
request.abort();
|
||
}
|
||
});
|
||
this.activeRequests = [];
|
||
}
|
||
}
|
||
|
||
removeRequest(request) {
|
||
const index = this.activeRequests.indexOf(request);
|
||
if (index > -1) {
|
||
this.activeRequests.splice(index, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Initialize all viewers on page
|
||
$(document).ready(() => {
|
||
console.log('MCB: Document ready, looking for .maple-code-blocks elements');
|
||
const viewers = [];
|
||
|
||
$('.maple-code-blocks').each(function() {
|
||
console.log('MCB: Initializing viewer for element:', this);
|
||
try {
|
||
const viewer = new GitHubCodeViewer(this);
|
||
viewers.push(viewer);
|
||
console.log('MCB: Viewer initialized successfully');
|
||
} catch (error) {
|
||
console.error('MCB: Error initializing viewer:', error);
|
||
}
|
||
});
|
||
|
||
console.log('MCB: Total viewers initialized:', viewers.length);
|
||
|
||
// Cleanup on page unload to prevent memory leaks
|
||
$(window).on('beforeunload', () => {
|
||
viewers.forEach(viewer => {
|
||
if (viewer && viewer.cleanupBeforeUnload) {
|
||
viewer.cleanupBeforeUnload();
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
})(jQuery);
|