@wonderwhy-er/desktop-commander
Version:
MCP server for terminal operations and file editing
241 lines (240 loc) • 10.5 kB
JavaScript
import { escapeHtml } from './components/highlighting.js';
import { buildRenderPayload, extractToolText } from './payload-utils.js';
function parseDirectoryEntries(content) {
const lines = content.split('\n');
const hintLines = [];
const entryLines = [];
for (const line of lines) {
if (/^\[(DIR|FILE|DENIED|NOT_FOUND|WARNING)\]/.test(line.trim())) {
entryLines.push(line.trim());
}
else if (entryLines.length === 0) {
hintLines.push(line);
}
}
const flat = [];
for (const line of entryLines) {
if (line.startsWith('[WARNING]')) {
const warnBody = line.replace(/^\[WARNING\]\s*/, '');
const colonIdx = warnBody.indexOf(':');
const dirName = colonIdx >= 0 ? warnBody.slice(0, colonIdx).trim() : '';
const msg = colonIdx >= 0 ? warnBody.slice(colonIdx + 1).trim() : warnBody;
const parts = dirName.replace(/\\/g, '/').split('/').filter(Boolean);
flat.push({
name: dirName,
fullPath: dirName,
isDir: false,
isDenied: false,
isNotFound: false,
isWarning: true,
warningText: msg,
depth: parts.length,
});
continue;
}
const isDir = line.startsWith('[DIR]');
const isDenied = line.startsWith('[DENIED]');
const isNotFound = line.startsWith('[NOT_FOUND]');
const name = line.replace(/^\[(DIR|FILE|DENIED|NOT_FOUND)\]\s*/, '');
const parts = name.replace(/\\/g, '/').split('/');
flat.push({
name,
fullPath: name,
isDir,
isDenied,
isNotFound,
isWarning: false,
warningText: '',
depth: parts.length - 1,
});
}
const root = [];
const stack = [root];
for (const item of flat) {
const baseName = item.fullPath.replace(/\\/g, '/').split('/').pop() ?? item.fullPath;
const entry = {
name: baseName,
isDir: item.isDir,
isDenied: item.isDenied,
isNotFound: item.isNotFound,
isWarning: item.isWarning,
warningText: item.warningText,
children: [],
relativePath: item.fullPath,
};
while (stack.length > item.depth + 1) {
stack.pop();
}
const parent = stack[stack.length - 1];
parent.push(entry);
if (item.isDir) {
stack.push(entry.children);
}
}
return { hint: hintLines.join('\n').trim(), entries: root };
}
let dirEntryIdCounter = 0;
function renderDirTree(entries, rootPath) {
if (entries.length === 0) {
return '<div class="dir-tree"><span class="dir-empty">Empty directory</span></div>';
}
function renderEntries(items) {
return items.map((item) => {
const id = `de-${dirEntryIdCounter++}`;
const fullPath = `${rootPath}/${item.relativePath.replace(/\\/g, '/')}`;
const escapedPath = escapeHtml(fullPath);
if (item.isWarning) {
return `<div class="dir-entry"><button class="dir-row dir-row-warning dir-load-more" data-loadpath="${escapedPath}"><span class="dir-warning-icon">⚠️</span> <span class="dir-warning-text">${escapeHtml(item.warningText)} — click to load all</span></button></div>`;
}
if (item.isDenied) {
return `<div class="dir-entry"><span class="dir-icon">🚫</span> <span class="dir-name-denied">${escapeHtml(item.name)}</span></div>`;
}
if (item.isNotFound) {
return `<div class="dir-entry"><span class="dir-icon">❓</span> <span class="dir-name-denied">${escapeHtml(item.name)}</span></div>`;
}
if (item.isDir) {
const hasChildren = item.children.length > 0;
const chevron = `<span class="dir-chevron${hasChildren ? ' expanded' : ''}">${hasChildren ? '▼' : '▶'}</span>`;
const openButton = `<button class="dir-open-btn" data-openpath="${escapedPath}" title="Open in Finder">📂</button>`;
const childrenHtml = hasChildren ? `<div class="dir-children" id="${id}-ch">${renderEntries(item.children)}</div>` : '';
return `<div class="dir-entry-group" id="${id}"><div class="dir-row dir-row-folder" data-path="${escapedPath}" data-eid="${id}" data-loaded="${hasChildren}">${chevron} <span class="dir-icon">📁</span> <span class="dir-name">${escapeHtml(item.name)}</span>${openButton}</div>${childrenHtml}</div>`;
}
return `<div class="dir-entry"><div class="dir-row dir-row-file" data-path="${escapedPath}"><span class="file-icon">📄</span> <span class="file-name">${escapeHtml(item.name)}</span></div></div>`;
}).join('');
}
return `<div class="dir-tree">${renderEntries(entries)}</div>`;
}
export function renderDirectoryBody(content, rootPath) {
dirEntryIdCounter = 0;
const { hint, entries } = parseDirectoryEntries(content);
return {
notice: hint || undefined,
html: `<div class="panel-content directory-content">${renderDirTree(entries, rootPath)}</div>`,
};
}
export function attachDirectoryHandlers(options) {
const tree = options.container.querySelector('.dir-tree');
if (!tree) {
return;
}
tree.addEventListener('click', async (event) => {
const openBtn = event.target.closest('.dir-open-btn');
if (openBtn) {
event.stopPropagation();
const openPath = openBtn.dataset.openpath;
if (!openPath) {
return;
}
const command = options.buildOpenInFolderCommand(openPath);
if (command) {
try {
await options.callTool?.('start_process', { command, timeout_ms: 12000 });
}
catch {
// Keep UI stable if opening folder fails.
}
}
return;
}
const loadMoreBtn = event.target.closest('.dir-load-more');
if (loadMoreBtn) {
event.stopPropagation();
const loadPath = loadMoreBtn.dataset.loadpath;
if (!loadPath) {
return;
}
loadMoreBtn.querySelector('.dir-warning-text').textContent = 'Loading…';
loadMoreBtn.disabled = true;
try {
const result = await options.callTool?.('list_directory', { path: loadPath, depth: 1 });
const text = extractToolText(result) ?? '';
if (text) {
const parsed = parseDirectoryEntries(text);
const html = renderDirTree(parsed.entries, loadPath);
const parentChildren = loadMoreBtn.closest('.dir-children');
if (parentChildren) {
const temp = document.createElement('div');
temp.innerHTML = html;
const inner = temp.querySelector('.dir-tree');
parentChildren.innerHTML = inner ? inner.innerHTML : '';
}
}
}
catch {
loadMoreBtn.querySelector('.dir-warning-text').textContent = 'Failed to load';
loadMoreBtn.disabled = false;
}
return;
}
const target = event.target.closest('.dir-row');
if (!target) {
return;
}
const fullPath = target.dataset.path;
if (!fullPath) {
return;
}
if (target.classList.contains('dir-row-folder')) {
const entryId = target.dataset.eid;
if (!entryId) {
return;
}
const childrenEl = document.getElementById(`${entryId}-ch`);
const chevron = target.querySelector('.dir-chevron');
if (childrenEl) {
const hidden = childrenEl.classList.toggle('dir-collapsed');
chevron?.classList.toggle('expanded', !hidden);
if (chevron)
chevron.textContent = hidden ? '▶' : '▼';
return;
}
if (target.dataset.loaded === 'true') {
return;
}
if (chevron)
chevron.textContent = '⏳';
try {
const result = await options.callTool?.('list_directory', { path: fullPath, depth: 2 });
const text = extractToolText(result) ?? '';
if (text) {
target.dataset.loaded = 'true';
const parsed = parseDirectoryEntries(text);
const html = renderDirTree(parsed.entries, fullPath);
const wrapper = document.createElement('div');
wrapper.className = 'dir-children';
wrapper.id = `${entryId}-ch`;
const temp = document.createElement('div');
temp.innerHTML = html;
const inner = temp.querySelector('.dir-tree');
wrapper.innerHTML = inner ? inner.innerHTML : '<span class="dir-empty">Empty</span>';
target.parentElement?.appendChild(wrapper);
chevron?.classList.add('expanded');
if (chevron)
chevron.textContent = '▼';
}
}
catch {
if (chevron)
chevron.textContent = '⚠';
}
return;
}
if (target.classList.contains('dir-row-file')) {
target.classList.add('dir-loading');
try {
const result = await options.callTool?.('read_file', { path: fullPath });
if (!result || typeof result !== 'object' || result === null) {
return;
}
const structuredContent = result.structuredContent;
if (structuredContent && typeof structuredContent === 'object') {
const text = extractToolText(result) ?? '';
options.onOpenPayload(buildRenderPayload(structuredContent, text));
}
}
catch {
target.classList.remove('dir-loading');
}
}
});
}