// Directory tree for the sidebar file-explorer pane.
// Lazy-loads children when a node is expanded. Tracks one selected path.
// Row click = select + always expand. Chevron click = toggle collapse/expand.
let _onSelect = null;
let _selectedPath = null;
const ICON_CLOSED = ``;
const ICON_OPEN = ``;
const ICON_HOME = ``;
export function init(container, onSelect) {
_onSelect = onSelect;
container.innerHTML = '';
container.classList.add('tree-root');
}
export async function loadTree(rootPath, selectedPath) {
_selectedPath = selectedPath;
const container = document.querySelector('.file-explorer');
if (!container) return;
container.innerHTML = '';
const rootNode = createNode({
name: rootPath.split('/').pop() || rootPath,
path: rootPath,
hasChildren: true,
isRoot: true,
});
container.appendChild(rootNode);
await expandNode(rootNode, selectedPath);
}
function createNode({ name, path: nodePath, hasChildren, isRoot = false }) {
const wrap = document.createElement('div');
wrap.className = 'tree-node';
wrap.dataset.path = nodePath;
wrap.dataset.expanded = 'false';
wrap.dataset.hasChildren = hasChildren ? 'true' : 'false';
wrap.dataset.isRoot = isRoot ? 'true' : 'false';
const row = document.createElement('div');
row.className = 'tree-row';
const chevron = document.createElement('span');
chevron.className = 'tree-chevron';
if (hasChildren) {
chevron.innerHTML = ``;
}
const icon = document.createElement('span');
icon.className = 'tree-icon';
icon.innerHTML = isRoot ? ICON_HOME : ICON_CLOSED;
const label = document.createElement('span');
label.className = 'tree-label';
label.textContent = name;
label.title = nodePath;
row.appendChild(chevron);
row.appendChild(icon);
row.appendChild(label);
wrap.appendChild(row);
if (hasChildren) {
const children = document.createElement('div');
children.className = 'tree-children';
wrap.appendChild(children);
}
// Chevron click: toggle collapse/expand without changing selection
chevron.addEventListener('click', (e) => {
e.stopPropagation();
if (!hasChildren) return;
if (wrap.dataset.expanded === 'true') {
collapseNode(wrap);
} else {
expandNode(wrap, null);
}
});
// Row click: select + always expand (never collapse)
row.addEventListener('click', () => {
selectNode(wrap);
if (hasChildren && wrap.dataset.expanded !== 'true') {
expandNode(wrap, null);
}
});
return wrap;
}
function nodeIcon(nodeEl) {
return nodeEl.dataset.isRoot === 'true' ? ICON_HOME : ICON_OPEN;
}
function selectNode(nodeEl) {
const path = nodeEl.dataset.path;
document.querySelectorAll('.tree-row.active').forEach(r => r.classList.remove('active'));
nodeEl.querySelector(':scope > .tree-row').classList.add('active');
_selectedPath = path;
if (_onSelect) _onSelect(path);
}
function collapseNode(nodeEl) {
nodeEl.dataset.expanded = 'false';
const row = nodeEl.querySelector(':scope > .tree-row');
row.querySelector('.tree-chevron').classList.remove('open');
row.querySelector('.tree-icon').innerHTML = nodeEl.dataset.isRoot === 'true' ? ICON_HOME : ICON_CLOSED;
const children = nodeEl.querySelector(':scope > .tree-children');
if (children) children.style.display = 'none';
}
async function expandNode(nodeEl, autoExpandPath) {
nodeEl.dataset.expanded = 'true';
const row = nodeEl.querySelector(':scope > .tree-row');
row.querySelector('.tree-chevron').classList.add('open');
row.querySelector('.tree-icon').innerHTML = nodeIcon(nodeEl);
const childrenEl = nodeEl.querySelector(':scope > .tree-children');
if (!childrenEl) return;
childrenEl.style.display = '';
// Lazy-load children on first expansion
if (!childrenEl.dataset.loaded) {
childrenEl.dataset.loaded = 'true';
const dirs = await window.api.listDirectories(nodeEl.dataset.path);
if (dirs === null) {
const err = document.createElement('div');
err.className = 'tree-access-error';
err.textContent = '접근 불가';
childrenEl.appendChild(err);
} else {
dirs.forEach(dir => childrenEl.appendChild(createNode(dir)));
}
}
// Auto-expand toward the target path
if (autoExpandPath && autoExpandPath !== nodeEl.dataset.path) {
const childNodes = childrenEl.querySelectorAll(':scope > .tree-node');
for (const child of childNodes) {
const cp = child.dataset.path;
if (autoExpandPath === cp || autoExpandPath.startsWith(cp + '/')) {
await expandNode(child, autoExpandPath);
break;
}
}
}
// Reached the target — select it
if (autoExpandPath === nodeEl.dataset.path) {
selectNode(nodeEl);
}
}
// Mark a path as selected and ensure the node is expanded to show its children.
// Called from renderer when a folder is loaded externally (e.g. "폴더 열기" dialog).
export async function setSelectedPath(folderPath) {
_selectedPath = folderPath;
document.querySelectorAll('.tree-row.active').forEach(r => r.classList.remove('active'));
const node = document.querySelector(`.tree-node[data-path="${CSS.escape(folderPath)}"]`);
if (!node) return;
node.querySelector(':scope > .tree-row').classList.add('active');
if (node.dataset.hasChildren === 'true' && node.dataset.expanded !== 'true') {
await expandNode(node, null);
}
}