import { show as monacoShow, destroy as monacoDestroy } from './monaco-editor.js';
import { isMobile } from './util.js';
export class CodeExplorer {
constructor() {
/** @type {Map<string, Element[]>} 复用最近一次 PJAX 返回的目录行,避免重复请求 */
this.prefetchedDirectoryRows = new Map();
this.init();
}
init() {
this.container = document.querySelector('.code-explorer');
if (!this.container) return;
if (this.container.dataset.jsInitialized === 'true') {
this.treeContainer = this.container.querySelector('.code-explorer__tree');
this.syncTreeWithUrl().then(() => { });
return;
}
this.treeContainer = this.container.querySelector('.code-explorer__tree');
this.contentContainer = this.container.querySelector('.code-explorer__content');
this.searchInput = this.container.querySelector('.code-explorer__search input');
this.searchButton = this.container.querySelector('.code-explorer__search .btn-search');
if (this.searchInput) {
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.handleSearch(this.searchInput.value).then(() => { });
}
});
}
if (this.searchButton && this.searchInput) {
this.searchButton.addEventListener('click', async () => {
await this.handleSearch(this.searchInput.value);
});
}
this.container.addEventListener('click', async (e) => await this.handleClick(e));
this._initResizeHandle();
this.syncTreeWithUrl().then(() => { });
this._renderCodeView();
this.container.dataset.jsInitialized = 'true';
}
/** 渲染当前页面的代码视图(首次加载或由外部调用) */
_renderCodeView() {
const wrapper = document.querySelector('.code-view-wrapper');
if (!wrapper) return;
if (isMobile()) {
if (typeof Prism !== 'undefined') {
const block = wrapper.querySelector('.prism-fallback code');
if (block) Prism.highlightElement(block);
}
} else {
const host = wrapper.querySelector('.monaco-host');
const code = wrapper.querySelector('.prism-fallback code')?.textContent ?? '';
const lang = host?.dataset.language ?? '';
if (host && code.trim()) {
monacoShow(host, code, lang).catch(err => {
console.warn('Monaco 初始化失败,回退到 Prism:', err);
wrapper.classList.add('use-prism');
if (typeof Prism !== 'undefined') {
const block = wrapper.querySelector('.prism-fallback code');
if (block) Prism.highlightElement(block);
}
});
}
}
}
async handleClick(e) {
// 展开/折叠按钮
const toggle = e.target.closest('.code-tree__toggle');
if (toggle) {
e.preventDefault();
e.stopPropagation();
await this.toggleNode(toggle.closest('.code-tree__node'));
return;
}
// 点击 .code-tree__content 任意区域均触发导航(不仅限于 <a> 标签)
const treeContent = e.target.closest('.code-tree__content');
const link = treeContent
? treeContent.querySelector('.code-tree__label')
: e.target.closest('a');
if (!link?.href) return;
let url;
try { url = new URL(link.href); } catch { return; }
if (url.origin !== window.location.origin) return;
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
if (!url.pathname.toLowerCase().startsWith('/code')) return;
e.preventDefault();
if (treeContent) {
const node = treeContent.closest('.code-tree__node');
this.selectNode(node);
if (node.dataset.isFolder === 'true') {
// 点击目录名称时不主动拉取子目录,避免和后续 PJAX 导航产生双请求。
// 真正的数据填充在 PJAX 响应后复用返回内容完成。
node.classList.add('is-expanded');
}
}
this.loadContent(link.href);
}
loadContent(url) {
const that = this;
if ($.fn.pjax) {
$.pjax({
url: url,
container: '.code-explorer__content',
fragment: '.code-explorer__content',
timeout: 5000,
push: true,
scrollTo: false,
});
$(document).one('pjax:success', function (event, data, status, xhr, options) {
const targetUrl = new URL(url, window.location.origin).href;
const requestUrl = new URL(options.url, window.location.origin).href;
if (targetUrl !== requestUrl) return;
that.updateTreeFromResponse(url, data);
that.updateBreadcrumbs(data);
const wrapper = document.querySelector('.code-view-wrapper');
if (wrapper) {
if (isMobile()) {
if (typeof Prism !== 'undefined') {
const block = wrapper.querySelector('.prism-fallback code');
if (block) Prism.highlightElement(block);
}
} else {
const host = wrapper.querySelector('.monaco-host');
const code = wrapper.querySelector('.prism-fallback code')?.textContent ?? '';
const lang = host?.dataset.language ?? '';
if (host && code.trim()) {
monacoShow(host, code, lang).catch(err => {
console.warn('Monaco 渲染失败,回退到 Prism:', err);
wrapper.classList.add('use-prism');
if (typeof Prism !== 'undefined') {
const block = wrapper.querySelector('.prism-fallback code');
if (block) Prism.highlightElement(block);
}
});
}
}
} else {
monacoDestroy();
}
window.scrollTo(0, 0);
});
} else {
window.location.href = url;
}
}
updateBreadcrumbs(htmlContent) {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const newBreadcrumbs = doc.querySelector('.code-explorer__breadcrumbs');
if (newBreadcrumbs) {
const currentBreadcrumbs = document.querySelector('.code-explorer__breadcrumbs');
if (currentBreadcrumbs) {
currentBreadcrumbs.innerHTML = newBreadcrumbs.innerHTML;
}
}
}
// 利用 PJAX 响应体更新树节点,避免二次 fetch
updateTreeFromResponse(url, htmlContent) {
const path = new URL(url, window.location.origin).pathname
.replace(/^\/code\/?/i, '').replace(/^\//, '');
const doc = new DOMParser().parseFromString(htmlContent, 'text/html');
const fileRows = Array.from(doc.querySelectorAll('.row'))
.filter(r => !r.classList.contains('prev'));
// 缓存本次响应内容,供 syncTreeWithUrl -> expandNode -> loadDirectory 复用。
this.prefetchedDirectoryRows.set(decodeURIComponent(path), fileRows);
if (fileRows.length === 0) {
this.syncTreeWithUrl().then(() => { });
return;
}
this.findNodeByPath(path).then(node => {
if (node && node.dataset.isFolder === 'true') {
const childrenContainer = node.querySelector('.code-tree__children');
if (childrenContainer && childrenContainer.dataset.loaded !== 'true') {
this.populateContainer(childrenContainer, fileRows);
node.classList.add('is-expanded');
}
}
this.syncTreeWithUrl().then(() => { });
});
}
async findNodeByPath(path) {
if (!path) return null;
const segments = path.split('/').filter(x => x);
let parentContainer = this.treeContainer;
let foundNode = null;
for (const segment of segments) {
const decoded = decodeURIComponent(segment);
let node = null;
for (const n of parentContainer.querySelectorAll(':scope > .code-tree__node')) {
if (n.dataset.name === decoded) { node = n; break; }
}
if (!node) return null;
foundNode = node;
if (node.dataset.isFolder === 'true') {
parentContainer = node.querySelector('.code-tree__children');
if (parentContainer.dataset.loaded !== 'true') return null;
}
}
return foundNode;
}
populateContainer(container, rows) {
if (container.dataset.loaded === 'true') return;
const fragment = document.createDocumentFragment();
rows.forEach(row => {
if (row.classList.contains('prev')) return;
const link = row.querySelector('.header a');
if (!link) return;
const name = row.dataset.name || link.getAttribute('title') || link.innerText.trim();
const href = link.getAttribute('href');
const iconContainer = row.querySelector('.icon');
const iconHtml = iconContainer ? iconContainer.innerHTML : '';
const isFolder = row.dataset.isFolder === 'true';
const node = this.createTreeNode(name, href, iconHtml, isFolder);
fragment.appendChild(node);
});
container.appendChild(fragment);
container.dataset.loaded = 'true';
}
async syncTreeWithUrl() {
if (!this.treeContainer) return;
const path = window.location.pathname.replace(/^\/code\/?/i, '').replace(/^\//, '');
const segments = path.split('/').filter(x => x);
if (this.treeContainer.children.length === 0) {
await this.loadDirectory('', this.treeContainer);
}
let currentPath = '';
let parentContainer = this.treeContainer;
this.treeContainer.querySelectorAll('.code-tree__content').forEach(el => el.classList.remove('is-active'));
for (const segment of segments) {
currentPath += (currentPath ? '/' : '') + segment;
const decoded = decodeURIComponent(segment);
let node = null;
for (const n of parentContainer.querySelectorAll(':scope > .code-tree__node')) {
if (n.dataset.name === decoded) { node = n; break; }
}
if (!node) break;
if (decodeURIComponent(currentPath) === decodeURIComponent(path)) {
this.selectNode(node);
}
if (node.dataset.isFolder === 'true') {
const children = node.querySelector('.code-tree__children');
if (children && children.dataset.loaded === 'true') {
node.classList.add('is-expanded');
} else {
await this.expandNode(node);
}
parentContainer = node.querySelector('.code-tree__children');
}
}
}
async loadDirectory(path, container) {
if (container.dataset.loaded === 'true') return;
const normalizedPath = decodeURIComponent((path || '').replace(/^\//, ''));
const prefetchedRows = this.prefetchedDirectoryRows.get(normalizedPath);
if (prefetchedRows) {
this.populateContainer(container, prefetchedRows);
this.prefetchedDirectoryRows.delete(normalizedPath);
return;
}
const url = `/code/${path}`;
try {
const response = await fetch(url, {
headers: { 'X-PJAX': 'true' }
});
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const rows = doc.querySelectorAll('.row');
this.populateContainer(container, rows);
} catch (e) {
console.error('Failed to load directory', path, e);
}
}
createTreeNode(name, href, iconHtml, isFolder) {
const div = document.createElement('div');
div.className = 'code-tree__node';
div.dataset.name = name;
div.dataset.isFolder = isFolder;
const content = document.createElement('div');
content.className = 'code-tree__content';
// Toggle/Expand Icon
const toggle = document.createElement('div');
toggle.className = `code-tree__toggle ${isFolder ? '' : 'is-hidden'}`;
toggle.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>';
content.appendChild(toggle);
// File/Folder Icon
const icon = document.createElement('div');
icon.className = 'code-tree__icon';
icon.innerHTML = iconHtml;
content.appendChild(icon);
// Label
const label = document.createElement('a');
label.className = 'code-tree__label';
label.href = href;
label.innerText = name;
content.appendChild(label);
content.title = name;
div.appendChild(content);
if (isFolder) {
const children = document.createElement('div');
children.className = 'code-tree__children';
div.appendChild(children);
}
return div;
}
async toggleNode(node) {
if (node.dataset.isFolder !== 'true') return;
if (node.classList.contains('is-expanded')) {
node.classList.remove('is-expanded');
} else {
await this.expandNode(node);
}
}
async expandNode(node) {
if (!node || node.dataset.isFolder !== 'true') return;
node.classList.add('is-expanded');
const childrenContainer = node.querySelector('.code-tree__children');
if (childrenContainer && childrenContainer.dataset.loaded !== 'true') {
const link = node.querySelector('.code-tree__label');
const href = link.getAttribute('href');
const path = href.replace(/^\/code\/?/i, '').replace(/^\//, '');
node.classList.add('is-loading');
await this.loadDirectory(path, childrenContainer);
node.classList.remove('is-loading');
}
}
selectNode(node) {
if (!node) return;
this.treeContainer.querySelectorAll('.code-tree__content').forEach(el => el.classList.remove('is-active'));
const content = node.querySelector('.code-tree__content');
if (content) {
content.classList.add('is-active');
this._scrollNodeIntoView(content);
}
}
/** 让当前选中树节点在可滚动容器中可见。 */
_scrollNodeIntoView(contentEl) {
if (!this.treeContainer || !contentEl) return;
const containerRect = this.treeContainer.getBoundingClientRect();
const nodeRect = contentEl.getBoundingClientRect();
const overTop = nodeRect.top < containerRect.top;
const overBottom = nodeRect.bottom > containerRect.bottom;
if (overTop || overBottom) {
// 选中节点尽量滚动到容器中部
const containerCenter = this.treeContainer.clientHeight / 2;
const nodeTopInContainer =
this.treeContainer.scrollTop + (nodeRect.top - containerRect.top);
const targetScrollTop =
nodeTopInContainer - containerCenter + nodeRect.height / 2;
const maxScrollTop = this.treeContainer.scrollHeight - this.treeContainer.clientHeight;
const nextScrollTop = Math.max(0, Math.min(targetScrollTop, Math.max(0, maxScrollTop)));
this.treeContainer.scrollTo({
top: nextScrollTop,
behavior: 'smooth',
});
}
}
_initResizeHandle() {
const sidebar = this.container.querySelector('.code-explorer__sidebar');
if (!sidebar) return;
const handle = document.createElement('div');
handle.className = 'code-explorer__resize-handle';
sidebar.insertAdjacentElement('afterend', handle);
let startX, startWidth;
handle.addEventListener('mousedown', (e) => {
startX = e.clientX;
startWidth = sidebar.offsetWidth;
handle.classList.add('is-resizing');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const onMove = (e) => {
const w = Math.min(Math.max(startWidth + e.clientX - startX, 160), 600);
sidebar.style.width = w + 'px';
};
const onUp = () => {
handle.classList.remove('is-resizing');
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
async handleSearch(keyword) {
keyword = keyword.trim();
if (!keyword) {
this.treeContainer.innerHTML = '';
this.treeContainer.dataset.loaded = 'false';
await this.loadDirectory('', this.treeContainer);
return;
}
try {
const response = await fetch(`/search/code?keyword=${encodeURIComponent(keyword)}`);
if (!response.ok) return;
this.renderSearchResults(await response.json(), keyword);
} catch (e) {
console.error('Search failed', e);
}
}
renderSearchResults(data, keyword) {
if (!data) return;
this.treeContainer.innerHTML = '';
const fragment = document.createDocumentFragment();
const getIconHtml = (isFolder) => {
if (isFolder) {
return '<svg viewBox="0 0 24 24" width="18" height="18" fill="#F4BF50"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>';
} else {
return '<svg viewBox="0 0 24 24" width="18" height="18" fill="#90A4AE"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>';
}
};
const renderItems = (items, isFolder) => {
if (!items) return;
items.forEach(item => {
const name = item.name;
const pathList = item.currentPath || [];
// Construct href
const href = '/code/' + pathList.join('/');
const node = this.createTreeNode(name, href, getIconHtml(isFolder), isFolder);
const label = node.querySelector('.code-tree__label');
if (keyword) {
label.innerHTML = name.replace(new RegExp(`(${keyword})`, 'gi'), '<mark>$1</mark>');
}
const cnt = node.querySelector('.code-tree__content');
if (cnt) cnt.title = pathList.join('/');
fragment.appendChild(node);
});
};
renderItems(data.directories, true);
renderItems(data.files, false);
if (fragment.children.length === 0) {
const empty = document.createElement('div');
empty.className = 'text-center text-muted p-3';
empty.innerText = '没有找到匹配项';
fragment.appendChild(empty);
}
this.treeContainer.appendChild(fragment);
}
}
评论加载中...