Adicionar file explorer para projetos criados pelos agentes
This commit is contained in:
@@ -72,6 +72,12 @@
|
|||||||
<span>Histórico</span>
|
<span>Histórico</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="sidebar-nav-item">
|
||||||
|
<a href="#" class="sidebar-nav-link" data-section="files">
|
||||||
|
<i data-lucide="folder-open"></i>
|
||||||
|
<span>Projetos</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="sidebar-nav-item">
|
<li class="sidebar-nav-item">
|
||||||
<a href="#" class="sidebar-nav-link" data-section="settings">
|
<a href="#" class="sidebar-nav-link" data-section="settings">
|
||||||
<i data-lucide="settings"></i>
|
<i data-lucide="settings"></i>
|
||||||
@@ -578,6 +584,10 @@
|
|||||||
<div id="history-pagination"></div>
|
<div id="history-pagination"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="files" class="section" aria-label="Projetos" hidden>
|
||||||
|
<div id="files-container"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="settings" class="section" aria-label="Configurações" hidden>
|
<section id="settings" class="section" aria-label="Configurações" hidden>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -1377,6 +1387,7 @@
|
|||||||
<script src="js/components/history.js"></script>
|
<script src="js/components/history.js"></script>
|
||||||
<script src="js/components/webhooks.js"></script>
|
<script src="js/components/webhooks.js"></script>
|
||||||
<script src="js/components/notifications.js"></script>
|
<script src="js/components/notifications.js"></script>
|
||||||
|
<script src="js/components/files.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
<script>
|
<script>
|
||||||
Utils.refreshIcons();
|
Utils.refreshIcons();
|
||||||
|
|||||||
@@ -5166,3 +5166,168 @@ body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metr
|
|||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid var(--border-primary);
|
border-top: 1px solid var(--border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* File Explorer */
|
||||||
|
#files-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-breadcrumb-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-breadcrumb-link:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-breadcrumb-link:last-child {
|
||||||
|
color: var(--text-primary);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-breadcrumb-sep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table-wrapper {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table thead {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-table td {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-row:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-row:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-td-name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-th-size,
|
||||||
|
.files-td-size {
|
||||||
|
width: 100px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-th-date,
|
||||||
|
.files-td-date {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-th-actions,
|
||||||
|
.files-td-actions {
|
||||||
|
width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-entry-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-entry-dir {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-entry-dir:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-entry-file {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 80px 20px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.files-th-date,
|
||||||
|
.files-td-date {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.files-th-size,
|
||||||
|
.files-td-size {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ const API = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
files: {
|
||||||
|
list(path) { return API.request('GET', `/files${path ? '?path=' + encodeURIComponent(path) : ''}`); },
|
||||||
|
},
|
||||||
|
|
||||||
reports: {
|
reports: {
|
||||||
list() { return API.request('GET', '/reports'); },
|
list() { return API.request('GET', '/reports'); },
|
||||||
get(filename) { return API.request('GET', `/reports/${encodeURIComponent(filename)}`); },
|
get(filename) { return API.request('GET', `/reports/${encodeURIComponent(filename)}`); },
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ const App = {
|
|||||||
webhooks: 'Webhooks',
|
webhooks: 'Webhooks',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
history: 'Histórico',
|
history: 'Histórico',
|
||||||
|
files: 'Projetos',
|
||||||
settings: 'Configurações',
|
settings: 'Configurações',
|
||||||
},
|
},
|
||||||
|
|
||||||
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'settings'],
|
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'files', 'settings'],
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if (App._initialized) return;
|
if (App._initialized) return;
|
||||||
@@ -113,6 +114,7 @@ const App = {
|
|||||||
case 'pipelines': await PipelinesUI.load(); break;
|
case 'pipelines': await PipelinesUI.load(); break;
|
||||||
case 'webhooks': await WebhooksUI.load(); break;
|
case 'webhooks': await WebhooksUI.load(); break;
|
||||||
case 'history': await HistoryUI.load(); break;
|
case 'history': await HistoryUI.load(); break;
|
||||||
|
case 'files': await FilesUI.load(); break;
|
||||||
case 'settings': await SettingsUI.load(); break;
|
case 'settings': await SettingsUI.load(); break;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -763,6 +765,18 @@ const App = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('files-container')?.addEventListener('click', (e) => {
|
||||||
|
const el = e.target.closest('[data-action]');
|
||||||
|
if (!el) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const { action, path } = el.dataset;
|
||||||
|
switch (action) {
|
||||||
|
case 'navigate-files': FilesUI.navigate(path || ''); break;
|
||||||
|
case 'download-file': FilesUI.downloadFile(path); break;
|
||||||
|
case 'download-folder': FilesUI.downloadFolder(path); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('pipeline-steps-container')?.addEventListener('click', (e) => {
|
document.getElementById('pipeline-steps-container')?.addEventListener('click', (e) => {
|
||||||
const btn = e.target.closest('[data-step-action]');
|
const btn = e.target.closest('[data-step-action]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|||||||
152
public/js/components/files.js
Normal file
152
public/js/components/files.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
const FilesUI = {
|
||||||
|
currentPath: '',
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
await FilesUI.navigate('');
|
||||||
|
},
|
||||||
|
|
||||||
|
async navigate(path) {
|
||||||
|
try {
|
||||||
|
const data = await API.files.list(path);
|
||||||
|
FilesUI.currentPath = data.path || '';
|
||||||
|
FilesUI.render(data);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(`Erro ao carregar arquivos: ${err.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render(data) {
|
||||||
|
const container = document.getElementById('files-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const breadcrumb = FilesUI._renderBreadcrumb(data.path);
|
||||||
|
const entries = data.entries || [];
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
${breadcrumb}
|
||||||
|
<div class="files-empty">
|
||||||
|
<i data-lucide="folder-open" style="width:48px;height:48px;color:var(--text-muted)"></i>
|
||||||
|
<p>Nenhum arquivo encontrado neste diretório</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
Utils.refreshIcons(container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = entries.map(entry => FilesUI._renderRow(entry, data.path)).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
${breadcrumb}
|
||||||
|
<div class="files-toolbar">
|
||||||
|
<span class="files-count">${entries.length} ${entries.length === 1 ? 'item' : 'itens'}</span>
|
||||||
|
${data.path ? `<button class="btn btn--ghost btn--sm" data-action="download-folder" data-path="${Utils.escapeHtml(data.path)}" title="Baixar pasta como .tar.gz"><i data-lucide="archive" style="width:14px;height:14px"></i> Baixar pasta</button>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="files-table-wrapper">
|
||||||
|
<table class="files-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="files-th-name">Nome</th>
|
||||||
|
<th class="files-th-size">Tamanho</th>
|
||||||
|
<th class="files-th-date">Modificado</th>
|
||||||
|
<th class="files-th-actions"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Utils.refreshIcons(container);
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderBreadcrumb(currentPath) {
|
||||||
|
const parts = currentPath ? currentPath.split('/').filter(Boolean) : [];
|
||||||
|
let html = `<nav class="files-breadcrumb"><a href="#" data-action="navigate-files" data-path="" class="files-breadcrumb-link"><i data-lucide="home" style="width:14px;height:14px"></i> projetos</a>`;
|
||||||
|
|
||||||
|
let accumulated = '';
|
||||||
|
for (const part of parts) {
|
||||||
|
accumulated += (accumulated ? '/' : '') + part;
|
||||||
|
html += ` <span class="files-breadcrumb-sep">/</span> <a href="#" data-action="navigate-files" data-path="${Utils.escapeHtml(accumulated)}" class="files-breadcrumb-link">${Utils.escapeHtml(part)}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</nav>';
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderRow(entry, currentPath) {
|
||||||
|
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
||||||
|
const icon = entry.type === 'directory' ? 'folder' : FilesUI._fileIcon(entry.extension);
|
||||||
|
const iconColor = entry.type === 'directory' ? 'var(--warning)' : 'var(--text-muted)';
|
||||||
|
const size = entry.type === 'directory' ? '—' : FilesUI._formatSize(entry.size);
|
||||||
|
const date = FilesUI._formatDate(entry.modified);
|
||||||
|
|
||||||
|
const nameCell = entry.type === 'directory'
|
||||||
|
? `<a href="#" class="files-entry-link files-entry-dir" data-action="navigate-files" data-path="${Utils.escapeHtml(fullPath)}"><i data-lucide="${icon}" style="width:16px;height:16px;color:${iconColor};flex-shrink:0"></i> ${Utils.escapeHtml(entry.name)}</a>`
|
||||||
|
: `<span class="files-entry-link files-entry-file"><i data-lucide="${icon}" style="width:16px;height:16px;color:${iconColor};flex-shrink:0"></i> ${Utils.escapeHtml(entry.name)}</span>`;
|
||||||
|
|
||||||
|
const actions = entry.type === 'directory'
|
||||||
|
? `<button class="btn btn--ghost btn--sm" data-action="download-folder" data-path="${Utils.escapeHtml(fullPath)}" title="Baixar pasta"><i data-lucide="archive" style="width:14px;height:14px"></i></button>`
|
||||||
|
: `<button class="btn btn--ghost btn--sm" data-action="download-file" data-path="${Utils.escapeHtml(fullPath)}" title="Baixar arquivo"><i data-lucide="download" style="width:14px;height:14px"></i></button>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="files-row">
|
||||||
|
<td class="files-td-name">${nameCell}</td>
|
||||||
|
<td class="files-td-size">${size}</td>
|
||||||
|
<td class="files-td-date">${date}</td>
|
||||||
|
<td class="files-td-actions">${actions}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_fileIcon(ext) {
|
||||||
|
const map = {
|
||||||
|
js: 'file-code-2', ts: 'file-code-2', jsx: 'file-code-2', tsx: 'file-code-2',
|
||||||
|
py: 'file-code-2', rb: 'file-code-2', go: 'file-code-2', rs: 'file-code-2',
|
||||||
|
java: 'file-code-2', c: 'file-code-2', cpp: 'file-code-2', h: 'file-code-2',
|
||||||
|
html: 'file-code-2', css: 'file-code-2', scss: 'file-code-2', vue: 'file-code-2',
|
||||||
|
json: 'file-json', xml: 'file-json', yaml: 'file-json', yml: 'file-json',
|
||||||
|
md: 'file-text', txt: 'file-text', log: 'file-text', csv: 'file-text',
|
||||||
|
pdf: 'file-text',
|
||||||
|
png: 'file-image', jpg: 'file-image', jpeg: 'file-image', gif: 'file-image',
|
||||||
|
svg: 'file-image', webp: 'file-image', ico: 'file-image',
|
||||||
|
zip: 'file-archive', tar: 'file-archive', gz: 'file-archive', rar: 'file-archive',
|
||||||
|
sh: 'file-terminal', bash: 'file-terminal',
|
||||||
|
sql: 'database',
|
||||||
|
env: 'file-lock',
|
||||||
|
};
|
||||||
|
return map[ext] || 'file';
|
||||||
|
},
|
||||||
|
|
||||||
|
_formatSize(bytes) {
|
||||||
|
if (bytes == null) return '—';
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||||
|
},
|
||||||
|
|
||||||
|
_formatDate(isoString) {
|
||||||
|
if (!isoString) return '—';
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleDateString('pt-BR') + ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadFile(path) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `/api/files/download?path=${encodeURIComponent(path)}`;
|
||||||
|
a.download = '';
|
||||||
|
a.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadFolder(path) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `/api/files/download-folder?path=${encodeURIComponent(path)}`;
|
||||||
|
a.download = '';
|
||||||
|
a.click();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.FilesUI = FilesUI;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { execFile } from 'child_process';
|
import { execFile, spawn as spawnProcess } from 'child_process';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
@@ -11,8 +11,10 @@ import * as pipeline from '../agents/pipeline.js';
|
|||||||
import { getBinPath, updateMaxConcurrent, cancelAllExecutions, getActiveExecutions } from '../agents/executor.js';
|
import { getBinPath, updateMaxConcurrent, cancelAllExecutions, getActiveExecutions } from '../agents/executor.js';
|
||||||
import { invalidateAgentMapCache } from '../agents/pipeline.js';
|
import { invalidateAgentMapCache } from '../agents/pipeline.js';
|
||||||
import { cached } from '../cache/index.js';
|
import { cached } from '../cache/index.js';
|
||||||
import { readdirSync, readFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
import { readdirSync, readFileSync, unlinkSync, existsSync, mkdirSync, statSync, createReadStream } from 'fs';
|
||||||
import { join, dirname, resolve as pathResolve, extname } from 'path';
|
import { join, dirname, resolve as pathResolve, extname, basename, relative } from 'path';
|
||||||
|
import { createGzip } from 'zlib';
|
||||||
|
import { Readable } from 'stream';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __apiDirname = dirname(fileURLToPath(import.meta.url));
|
const __apiDirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -1037,4 +1039,103 @@ router.delete('/reports/:filename', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const PROJECTS_DIR = '/home/projetos';
|
||||||
|
|
||||||
|
function resolveProjectPath(requestedPath) {
|
||||||
|
const decoded = decodeURIComponent(requestedPath || '');
|
||||||
|
const resolved = pathResolve(PROJECTS_DIR, decoded);
|
||||||
|
if (!resolved.startsWith(PROJECTS_DIR)) return null;
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/files', (req, res) => {
|
||||||
|
try {
|
||||||
|
const targetPath = resolveProjectPath(req.query.path || '');
|
||||||
|
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
|
||||||
|
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Diretório não encontrado' });
|
||||||
|
|
||||||
|
const stat = statSync(targetPath);
|
||||||
|
if (!stat.isDirectory()) return res.status(400).json({ error: 'Caminho não é um diretório' });
|
||||||
|
|
||||||
|
const entries = readdirSync(targetPath, { withFileTypes: true })
|
||||||
|
.filter(e => !e.name.startsWith('.'))
|
||||||
|
.map(entry => {
|
||||||
|
const fullPath = join(targetPath, entry.name);
|
||||||
|
try {
|
||||||
|
const s = statSync(fullPath);
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
type: entry.isDirectory() ? 'directory' : 'file',
|
||||||
|
size: entry.isDirectory() ? null : s.size,
|
||||||
|
modified: s.mtime.toISOString(),
|
||||||
|
extension: entry.isDirectory() ? null : extname(entry.name).slice(1).toLowerCase(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const relativePath = relative(PROJECTS_DIR, targetPath) || '';
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
path: relativePath,
|
||||||
|
parent: relativePath ? dirname(relativePath) : null,
|
||||||
|
entries,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/files/download', (req, res) => {
|
||||||
|
try {
|
||||||
|
const targetPath = resolveProjectPath(req.query.path || '');
|
||||||
|
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
|
||||||
|
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Arquivo não encontrado' });
|
||||||
|
|
||||||
|
const stat = statSync(targetPath);
|
||||||
|
if (!stat.isFile()) return res.status(400).json({ error: 'Caminho não é um arquivo' });
|
||||||
|
|
||||||
|
const filename = basename(targetPath);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
|
||||||
|
res.setHeader('Content-Length', stat.size);
|
||||||
|
createReadStream(targetPath).pipe(res);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/files/download-folder', (req, res) => {
|
||||||
|
try {
|
||||||
|
const targetPath = resolveProjectPath(req.query.path || '');
|
||||||
|
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
|
||||||
|
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Pasta não encontrada' });
|
||||||
|
|
||||||
|
const stat = statSync(targetPath);
|
||||||
|
if (!stat.isDirectory()) return res.status(400).json({ error: 'Caminho não é uma pasta' });
|
||||||
|
|
||||||
|
const folderName = basename(targetPath) || 'projetos';
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(folderName)}.tar.gz"`);
|
||||||
|
res.setHeader('Content-Type', 'application/gzip');
|
||||||
|
|
||||||
|
const parentDir = dirname(targetPath);
|
||||||
|
const dirName = basename(targetPath);
|
||||||
|
const tar = spawnProcess('tar', ['-czf', '-', '-C', parentDir, dirName]);
|
||||||
|
tar.stdout.pipe(res);
|
||||||
|
tar.stderr.on('data', () => {});
|
||||||
|
tar.on('error', (err) => {
|
||||||
|
if (!res.headersSent) res.status(500).json({ error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('close', () => { try { tar.kill(); } catch {} });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
Reference in New Issue
Block a user