Importar projetos da máquina local via upload de pasta
Substitui o navegador de diretórios do servidor por upload de pasta local usando webkitdirectory. Filtra automaticamente .git, node_modules e padrões do .gitignore antes do envio. Cria o repo no Gitea, faz push e clona em /home/projetos/ para uso com agentes.
This commit is contained in:
@@ -5388,71 +5388,46 @@ body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metr
|
|||||||
.import-path-row {
|
.import-path-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.import-path-row .form-input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-browser {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border: 1px solid var(--border-primary);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-browser-header {
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-bottom: 1px solid var(--border-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-browser-list {
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-browser-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-bottom: 1px solid var(--border-primary);
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-browser-item:last-child {
|
.import-folder-display {
|
||||||
border-bottom: none;
|
flex: 1;
|
||||||
}
|
|
||||||
|
|
||||||
.import-browser-item:hover {
|
|
||||||
background: var(--bg-card-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-browser-dir {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
|
||||||
font-size: 13px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-browser-dir:hover {
|
.import-stat--muted {
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-browser-empty {
|
|
||||||
padding: 24px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
.import-select-btn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-repos-grid {
|
.import-repos-grid {
|
||||||
|
|||||||
@@ -151,7 +151,21 @@ const API = {
|
|||||||
|
|
||||||
projects: {
|
projects: {
|
||||||
browse(path) { return API.request('GET', `/browse?path=${encodeURIComponent(path || '/home')}`); },
|
browse(path) { return API.request('GET', `/browse?path=${encodeURIComponent(path || '/home')}`); },
|
||||||
import(sourcePath, repoName) { return API.request('POST', '/projects/import', { sourcePath, repoName }); },
|
importLocal(sourcePath, repoName) { return API.request('POST', '/projects/import', { sourcePath, repoName }); },
|
||||||
|
async upload(files, paths, repoName) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('repoName', repoName);
|
||||||
|
form.append('paths', JSON.stringify(paths));
|
||||||
|
for (const f of files) form.append('files', f);
|
||||||
|
const response = await fetch('/api/projects/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Client-Id': API.clientId },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || 'Erro no upload');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
files: {
|
files: {
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
const ImportUI = {
|
const ImportUI = {
|
||||||
_currentBrowsePath: '/home',
|
_selectedFiles: [],
|
||||||
_selectedPath: '',
|
_selectedPaths: [],
|
||||||
|
_folderName: '',
|
||||||
_importing: false,
|
_importing: false,
|
||||||
|
|
||||||
|
_excludedDirs: ['.git', 'node_modules', '__pycache__', '.next', '.nuxt', 'venv', '.venv', '.cache', '.parcel-cache', 'dist', 'build', '.output', '.svelte-kit', 'vendor', 'target', '.gradle', '.idea', '.vs', 'coverage', '.nyc_output'],
|
||||||
|
_excludedFiles: ['.DS_Store', 'Thumbs.db', 'desktop.ini', '*.pyc', '*.pyo', '*.class', '*.o', '*.so', '*.dll'],
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
const container = document.getElementById('import-container');
|
const container = document.getElementById('import-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
let repos = [];
|
let repos = [];
|
||||||
try {
|
try { repos = await API.repos.list(); } catch {}
|
||||||
repos = await API.repos.list();
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="import-layout">
|
<div class="import-layout">
|
||||||
@@ -19,26 +21,32 @@ const ImportUI = {
|
|||||||
<h2 class="card-title"><i data-lucide="upload-cloud" style="width:20px;height:20px"></i> Importar Projeto</h2>
|
<h2 class="card-title"><i data-lucide="upload-cloud" style="width:20px;height:20px"></i> Importar Projeto</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="import-desc">Selecione um diretório do servidor para importar ao Gitea. Os arquivos serão copiados respeitando o <code>.gitignore</code>, sem alterar o projeto original.</p>
|
<p class="import-desc">Selecione uma pasta do seu computador para enviar ao Gitea. Arquivos ignorados pelo <code>.gitignore</code> e pastas como <code>node_modules</code> serão filtrados automaticamente.</p>
|
||||||
|
|
||||||
|
<input type="file" id="import-folder-input" webkitdirectory directory multiple hidden />
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Diretório do projeto</label>
|
<label class="form-label">Pasta do projeto</label>
|
||||||
<div class="import-path-row">
|
<div class="import-path-row">
|
||||||
<input type="text" class="form-input" id="import-path" placeholder="/home/fred/meu-projeto" value="" />
|
<div class="import-folder-display" id="import-folder-display">
|
||||||
<button class="btn btn--ghost btn--sm" id="import-browse-btn" type="button"><i data-lucide="folder-search" style="width:16px;height:16px"></i> Navegar</button>
|
<i data-lucide="folder-open" style="width:18px;height:18px;color:var(--text-muted)"></i>
|
||||||
|
<span class="text-muted">Nenhuma pasta selecionada</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary btn--sm" id="import-select-btn" type="button">
|
||||||
|
<i data-lucide="folder-search" style="width:16px;height:16px"></i> Selecionar Pasta
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="import-browser" class="import-browser" hidden>
|
|
||||||
<div class="import-browser-header">
|
<div id="import-preview" class="import-preview" hidden></div>
|
||||||
<nav id="import-browser-breadcrumb" class="files-breadcrumb"></nav>
|
|
||||||
</div>
|
|
||||||
<div class="import-browser-list" id="import-browser-list"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Nome do repositório no Gitea</label>
|
<label class="form-label">Nome do repositório no Gitea</label>
|
||||||
<input type="text" class="form-input" id="import-repo-name" placeholder="meu-projeto" />
|
<input type="text" class="form-input" id="import-repo-name" placeholder="meu-projeto" />
|
||||||
<span class="form-hint">Letras minúsculas, números e hífens. Será criado no Gitea e clonado em /home/projetos/</span>
|
<span class="form-hint">Letras minúsculas, números e hífens</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--primary" id="import-submit-btn" type="button">
|
|
||||||
|
<button class="btn btn--primary" id="import-submit-btn" type="button" disabled>
|
||||||
<i data-lucide="upload-cloud" style="width:16px;height:16px"></i> Importar para o Gitea
|
<i data-lucide="upload-cloud" style="width:16px;height:16px"></i> Importar para o Gitea
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +75,7 @@ const ImportUI = {
|
|||||||
const domain = 'nitro-cloud.duckdns.org';
|
const domain = 'nitro-cloud.duckdns.org';
|
||||||
const repoUrl = `https://git.${domain}/${repo.full_name || repo.name}`;
|
const repoUrl = `https://git.${domain}/${repo.full_name || repo.name}`;
|
||||||
const updated = repo.updated_at ? new Date(repo.updated_at).toLocaleDateString('pt-BR') : '';
|
const updated = repo.updated_at ? new Date(repo.updated_at).toLocaleDateString('pt-BR') : '';
|
||||||
const size = repo.size ? ImportUI._formatSize(repo.size * 1024) : '';
|
const size = repo.size ? ImportUI._fmtSize(repo.size * 1024) : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="import-repo-card">
|
<div class="import-repo-card">
|
||||||
@@ -85,7 +93,7 @@ const ImportUI = {
|
|||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
|
|
||||||
_formatSize(bytes) {
|
_fmtSize(bytes) {
|
||||||
if (!bytes) return '';
|
if (!bytes) return '';
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
@@ -93,152 +101,170 @@ const ImportUI = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_bindEvents() {
|
_bindEvents() {
|
||||||
const browseBtn = document.getElementById('import-browse-btn');
|
const selectBtn = document.getElementById('import-select-btn');
|
||||||
|
const folderInput = document.getElementById('import-folder-input');
|
||||||
const submitBtn = document.getElementById('import-submit-btn');
|
const submitBtn = document.getElementById('import-submit-btn');
|
||||||
const pathInput = document.getElementById('import-path');
|
|
||||||
|
|
||||||
if (browseBtn) {
|
if (selectBtn && folderInput) {
|
||||||
browseBtn.addEventListener('click', () => {
|
selectBtn.addEventListener('click', () => folderInput.click());
|
||||||
const browser = document.getElementById('import-browser');
|
folderInput.addEventListener('change', () => ImportUI._onFolderSelected(folderInput.files));
|
||||||
if (!browser) return;
|
|
||||||
const isVisible = !browser.hidden;
|
|
||||||
browser.hidden = isVisible;
|
|
||||||
if (!isVisible) {
|
|
||||||
const currentVal = pathInput?.value.trim();
|
|
||||||
ImportUI._browseTo(currentVal || '/home');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathInput) {
|
|
||||||
pathInput.addEventListener('change', () => {
|
|
||||||
const val = pathInput.value.trim();
|
|
||||||
if (val) {
|
|
||||||
ImportUI._autoFillRepoName(val);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
pathInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
const val = pathInput.value.trim();
|
|
||||||
if (val) {
|
|
||||||
ImportUI._autoFillRepoName(val);
|
|
||||||
const browser = document.getElementById('import-browser');
|
|
||||||
if (browser && !browser.hidden) {
|
|
||||||
ImportUI._browseTo(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (submitBtn) {
|
if (submitBtn) {
|
||||||
submitBtn.addEventListener('click', () => ImportUI._doImport());
|
submitBtn.addEventListener('click', () => ImportUI._doUpload());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_autoFillRepoName(path) {
|
_shouldExclude(relativePath) {
|
||||||
const nameInput = document.getElementById('import-repo-name');
|
const parts = relativePath.split('/');
|
||||||
if (!nameInput || nameInput.value.trim()) return;
|
for (const part of parts.slice(0, -1)) {
|
||||||
const folderName = path.split('/').filter(Boolean).pop() || '';
|
if (ImportUI._excludedDirs.includes(part)) return true;
|
||||||
nameInput.value = folderName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
||||||
},
|
|
||||||
|
|
||||||
async _browseTo(path) {
|
|
||||||
try {
|
|
||||||
const data = await API.projects.browse(path);
|
|
||||||
ImportUI._currentBrowsePath = data.currentPath;
|
|
||||||
ImportUI._renderBrowser(data);
|
|
||||||
} catch (err) {
|
|
||||||
Toast.error(`Erro ao navegar: ${err.message}`);
|
|
||||||
}
|
}
|
||||||
},
|
const fileName = parts[parts.length - 1];
|
||||||
|
for (const pattern of ImportUI._excludedFiles) {
|
||||||
_renderBrowser(data) {
|
if (pattern.startsWith('*.')) {
|
||||||
const breadcrumbEl = document.getElementById('import-browser-breadcrumb');
|
if (fileName.endsWith(pattern.slice(1))) return true;
|
||||||
const listEl = document.getElementById('import-browser-list');
|
|
||||||
if (!breadcrumbEl || !listEl) return;
|
|
||||||
|
|
||||||
const parts = data.currentPath.split('/').filter(Boolean);
|
|
||||||
let breadcrumb = `<a href="#" class="files-breadcrumb-link import-browse-link" data-browse-path="/"><i data-lucide="hard-drive" style="width:14px;height:14px"></i> /</a>`;
|
|
||||||
let accumulated = '';
|
|
||||||
for (const part of parts) {
|
|
||||||
accumulated += '/' + part;
|
|
||||||
breadcrumb += ` <span class="files-breadcrumb-sep">/</span> <a href="#" class="files-breadcrumb-link import-browse-link" data-browse-path="${Utils.escapeHtml(accumulated)}">${Utils.escapeHtml(part)}</a>`;
|
|
||||||
}
|
|
||||||
breadcrumbEl.innerHTML = breadcrumb;
|
|
||||||
|
|
||||||
const dirs = data.directories || [];
|
|
||||||
if (dirs.length === 0) {
|
|
||||||
listEl.innerHTML = '<div class="import-browser-empty">Nenhum subdiretório encontrado</div>';
|
|
||||||
} else {
|
} else {
|
||||||
listEl.innerHTML = dirs.map(d => `
|
if (fileName === pattern) return true;
|
||||||
<div class="import-browser-item">
|
|
||||||
<a href="#" class="import-browse-link import-browser-dir" data-browse-path="${Utils.escapeHtml(d.path)}">
|
|
||||||
<i data-lucide="folder" style="width:16px;height:16px;color:var(--warning)"></i>
|
|
||||||
<span>${Utils.escapeHtml(d.name)}</span>
|
|
||||||
</a>
|
|
||||||
<button class="btn btn--primary btn--sm import-select-btn" data-select-path="${Utils.escapeHtml(d.path)}" data-select-name="${Utils.escapeHtml(d.name)}" type="button">Selecionar</button>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Utils.refreshIcons(breadcrumbEl);
|
|
||||||
Utils.refreshIcons(listEl);
|
|
||||||
|
|
||||||
breadcrumbEl.querySelectorAll('.import-browse-link').forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
ImportUI._browseTo(link.dataset.browsePath);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
listEl.querySelectorAll('.import-browse-link').forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
ImportUI._browseTo(link.dataset.browsePath);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
listEl.querySelectorAll('.import-select-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const selectedPath = btn.dataset.selectPath;
|
|
||||||
const selectedName = btn.dataset.selectName;
|
|
||||||
const pathInput = document.getElementById('import-path');
|
|
||||||
const nameInput = document.getElementById('import-repo-name');
|
|
||||||
if (pathInput) pathInput.value = selectedPath;
|
|
||||||
if (nameInput && !nameInput.value.trim()) {
|
|
||||||
nameInput.value = selectedName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
||||||
}
|
}
|
||||||
document.getElementById('import-browser').hidden = true;
|
return false;
|
||||||
ImportUI._selectedPath = selectedPath;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async _doImport() {
|
_parseGitignore(content) {
|
||||||
if (ImportUI._importing) return;
|
const patterns = [];
|
||||||
|
for (const raw of content.split('\n')) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith('#') || line.startsWith('!')) continue;
|
||||||
|
patterns.push(line.replace(/\/$/, ''));
|
||||||
|
}
|
||||||
|
return patterns;
|
||||||
|
},
|
||||||
|
|
||||||
|
_matchesGitignore(relativePath, patterns) {
|
||||||
|
const parts = relativePath.split('/');
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (pattern.includes('/')) {
|
||||||
|
if (relativePath.startsWith(pattern + '/') || relativePath === pattern) return true;
|
||||||
|
} else if (pattern.startsWith('*.')) {
|
||||||
|
const ext = pattern.slice(1);
|
||||||
|
if (relativePath.endsWith(ext)) return true;
|
||||||
|
} else {
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part === pattern) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
_onFolderSelected(fileList) {
|
||||||
|
if (!fileList || fileList.length === 0) return;
|
||||||
|
|
||||||
|
const allFiles = Array.from(fileList);
|
||||||
|
const firstPath = allFiles[0].webkitRelativePath || '';
|
||||||
|
ImportUI._folderName = firstPath.split('/')[0] || 'projeto';
|
||||||
|
|
||||||
|
let gitignorePatterns = [];
|
||||||
|
const gitignoreFile = allFiles.find(f => {
|
||||||
|
const rel = f.webkitRelativePath || '';
|
||||||
|
const parts = rel.split('/');
|
||||||
|
return parts.length === 2 && parts[1] === '.gitignore';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (gitignoreFile) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
gitignorePatterns = ImportUI._parseGitignore(e.target.result);
|
||||||
|
ImportUI._applyFilter(allFiles, gitignorePatterns);
|
||||||
|
};
|
||||||
|
reader.readAsText(gitignoreFile);
|
||||||
|
} else {
|
||||||
|
ImportUI._applyFilter(allFiles, []);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_applyFilter(allFiles, gitignorePatterns) {
|
||||||
|
const filtered = [];
|
||||||
|
const paths = [];
|
||||||
|
let totalSize = 0;
|
||||||
|
let excluded = 0;
|
||||||
|
|
||||||
|
for (const file of allFiles) {
|
||||||
|
const fullRel = file.webkitRelativePath || file.name;
|
||||||
|
const relWithoutRoot = fullRel.split('/').slice(1).join('/');
|
||||||
|
if (!relWithoutRoot) continue;
|
||||||
|
|
||||||
|
if (ImportUI._shouldExclude(relWithoutRoot)) { excluded++; continue; }
|
||||||
|
if (gitignorePatterns.length > 0 && ImportUI._matchesGitignore(relWithoutRoot, gitignorePatterns)) { excluded++; continue; }
|
||||||
|
|
||||||
|
filtered.push(file);
|
||||||
|
paths.push(fullRel);
|
||||||
|
totalSize += file.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportUI._selectedFiles = filtered;
|
||||||
|
ImportUI._selectedPaths = paths;
|
||||||
|
|
||||||
|
const display = document.getElementById('import-folder-display');
|
||||||
|
if (display) {
|
||||||
|
display.innerHTML = `
|
||||||
|
<i data-lucide="folder" style="width:18px;height:18px;color:var(--warning)"></i>
|
||||||
|
<strong>${Utils.escapeHtml(ImportUI._folderName)}</strong>
|
||||||
|
`;
|
||||||
|
Utils.refreshIcons(display);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = document.getElementById('import-preview');
|
||||||
|
if (preview) {
|
||||||
|
preview.hidden = false;
|
||||||
|
preview.innerHTML = `
|
||||||
|
<div class="import-preview-stats">
|
||||||
|
<div class="import-stat">
|
||||||
|
<i data-lucide="file" style="width:16px;height:16px"></i>
|
||||||
|
<span><strong>${filtered.length}</strong> arquivos selecionados</span>
|
||||||
|
</div>
|
||||||
|
<div class="import-stat">
|
||||||
|
<i data-lucide="hard-drive" style="width:16px;height:16px"></i>
|
||||||
|
<span><strong>${ImportUI._fmtSize(totalSize)}</strong> total</span>
|
||||||
|
</div>
|
||||||
|
${excluded > 0 ? `<div class="import-stat import-stat--muted">
|
||||||
|
<i data-lucide="eye-off" style="width:16px;height:16px"></i>
|
||||||
|
<span>${excluded} arquivos ignorados (.gitignore / node_modules / etc.)</span>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
Utils.refreshIcons(preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameInput = document.getElementById('import-repo-name');
|
||||||
|
if (nameInput && !nameInput.value.trim()) {
|
||||||
|
nameInput.value = ImportUI._folderName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('import-submit-btn');
|
||||||
|
if (submitBtn) submitBtn.disabled = filtered.length === 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async _doUpload() {
|
||||||
|
if (ImportUI._importing) return;
|
||||||
|
if (ImportUI._selectedFiles.length === 0) { Toast.warning('Selecione uma pasta primeiro'); return; }
|
||||||
|
|
||||||
const pathInput = document.getElementById('import-path');
|
|
||||||
const nameInput = document.getElementById('import-repo-name');
|
const nameInput = document.getElementById('import-repo-name');
|
||||||
const submitBtn = document.getElementById('import-submit-btn');
|
const submitBtn = document.getElementById('import-submit-btn');
|
||||||
const sourcePath = pathInput?.value.trim();
|
const repoName = (nameInput?.value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
const repoName = nameInput?.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
||||||
|
|
||||||
if (!sourcePath) { Toast.warning('Informe o caminho do projeto'); return; }
|
|
||||||
if (!repoName) { Toast.warning('Informe o nome do repositório'); return; }
|
if (!repoName) { Toast.warning('Informe o nome do repositório'); return; }
|
||||||
|
|
||||||
ImportUI._importing = true;
|
ImportUI._importing = true;
|
||||||
if (submitBtn) {
|
if (submitBtn) {
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px" class="spin"></i> Importando...';
|
submitBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px" class="spin"></i> Enviando...';
|
||||||
Utils.refreshIcons(submitBtn);
|
Utils.refreshIcons(submitBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Toast.info('Importando projeto... isso pode levar alguns segundos');
|
Toast.info(`Enviando ${ImportUI._selectedFiles.length} arquivos...`);
|
||||||
const result = await API.projects.import(sourcePath, repoName);
|
const result = await API.projects.upload(ImportUI._selectedFiles, ImportUI._selectedPaths, repoName);
|
||||||
|
|
||||||
Toast.success('Projeto importado com sucesso!');
|
Toast.success('Projeto importado com sucesso!');
|
||||||
|
|
||||||
@@ -261,7 +287,9 @@ const ImportUI = {
|
|||||||
Modal.open('execution-detail-modal-overlay');
|
Modal.open('execution-detail-modal-overlay');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathInput) pathInput.value = '';
|
ImportUI._selectedFiles = [];
|
||||||
|
ImportUI._selectedPaths = [];
|
||||||
|
ImportUI._folderName = '';
|
||||||
if (nameInput) nameInput.value = '';
|
if (nameInput) nameInput.value = '';
|
||||||
App._reposCache = null;
|
App._reposCache = null;
|
||||||
await ImportUI.load();
|
await ImportUI.load();
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ const upload = multer({
|
|||||||
limits: { fileSize: 10 * 1024 * 1024, files: 20 },
|
limits: { fileSize: 10 * 1024 * 1024, files: 20 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const importUpload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024, files: 10000 },
|
||||||
|
});
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
export const hookRouter = Router();
|
export const hookRouter = Router();
|
||||||
|
|
||||||
@@ -1456,4 +1461,100 @@ router.post('/projects/import', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/projects/upload', importUpload.array('files', 10000), async (req, res) => {
|
||||||
|
const repoName = (req.body.repoName || '').toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
|
if (!repoName) return res.status(400).json({ error: 'repoName é obrigatório' });
|
||||||
|
|
||||||
|
let paths;
|
||||||
|
try { paths = JSON.parse(req.body.paths || '[]'); } catch { return res.status(400).json({ error: 'paths inválido' }); }
|
||||||
|
|
||||||
|
const files = req.files || [];
|
||||||
|
if (files.length === 0) return res.status(400).json({ error: 'Nenhum arquivo enviado' });
|
||||||
|
if (files.length !== paths.length) return res.status(400).json({ error: 'Quantidade de files e paths diverge' });
|
||||||
|
|
||||||
|
const GITEA_URL = process.env.GITEA_URL || 'http://gitea:3000';
|
||||||
|
const GITEA_USER = process.env.GITEA_USER || 'fred';
|
||||||
|
const GITEA_PASS = process.env.GITEA_PASS || '';
|
||||||
|
const DOMAIN = process.env.DOMAIN || 'nitro-cloud.duckdns.org';
|
||||||
|
if (!GITEA_PASS) return res.status(500).json({ error: 'GITEA_PASS não configurado' });
|
||||||
|
|
||||||
|
const steps = [];
|
||||||
|
const tmpDir = join(os.tmpdir(), `upload-${Date.now()}`);
|
||||||
|
|
||||||
|
const exec = (cmd, cwd) => new Promise((resolve, reject) => {
|
||||||
|
const proc = spawnProcess('sh', ['-c', cmd], { cwd: cwd || tmpDir, env: { ...process.env, HOME: '/tmp', GIT_TERMINAL_PROMPT: '0' } });
|
||||||
|
let stdout = '', stderr = '';
|
||||||
|
proc.stdout.on('data', d => stdout += d);
|
||||||
|
proc.stderr.on('data', d => stderr += d);
|
||||||
|
proc.on('close', code => code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `exit ${code}`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
mkdirSync(tmpDir, { recursive: true });
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const relativePath = paths[i].split('/').slice(1).join('/');
|
||||||
|
if (!relativePath || relativePath.includes('..')) continue;
|
||||||
|
const dest = join(tmpDir, relativePath);
|
||||||
|
mkdirSync(dirname(dest), { recursive: true });
|
||||||
|
const { writeFileSync: wfs } = await import('fs');
|
||||||
|
wfs(dest, files[i].buffer);
|
||||||
|
}
|
||||||
|
steps.push(`${files.length} arquivos recebidos`);
|
||||||
|
|
||||||
|
const authHeader = 'Basic ' + Buffer.from(`${GITEA_USER}:${GITEA_PASS}`).toString('base64');
|
||||||
|
let repoExists = false;
|
||||||
|
try {
|
||||||
|
const check = await fetch(`${GITEA_URL}/api/v1/repos/${GITEA_USER}/${repoName}`, { headers: { Authorization: authHeader } });
|
||||||
|
repoExists = check.ok;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (!repoExists) {
|
||||||
|
const createRes = await fetch(`${GITEA_URL}/api/v1/user/repos`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
||||||
|
body: JSON.stringify({ name: repoName, auto_init: false, private: false }),
|
||||||
|
});
|
||||||
|
if (!createRes.ok) throw new Error('Erro ao criar repositório no Gitea');
|
||||||
|
steps.push('Repositório criado no Gitea');
|
||||||
|
} else {
|
||||||
|
steps.push('Repositório já existe no Gitea');
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoUrl = `${GITEA_URL.replace('://', `://${GITEA_USER}:${GITEA_PASS}@`)}/${GITEA_USER}/${repoName}.git`;
|
||||||
|
await exec('git init');
|
||||||
|
await exec('git add -A');
|
||||||
|
await exec(`git -c user.name="Agents Orchestrator" -c user.email="agents@${DOMAIN}" commit -m "Import do projeto ${repoName}"`);
|
||||||
|
await exec(`git remote add origin "${repoUrl}"`);
|
||||||
|
await exec('git push -u origin HEAD:main --force');
|
||||||
|
steps.push('Push realizado para o Gitea');
|
||||||
|
|
||||||
|
const projectsDir = '/home/projetos';
|
||||||
|
const targetDir = join(projectsDir, repoName);
|
||||||
|
if (existsSync(targetDir)) {
|
||||||
|
await exec(`git remote set-url origin "${repoUrl}"`, targetDir);
|
||||||
|
await exec('git fetch origin', targetDir);
|
||||||
|
await exec('git reset --hard origin/main', targetDir);
|
||||||
|
steps.push('Projeto atualizado em /home/projetos/');
|
||||||
|
} else {
|
||||||
|
await exec(`git clone "${repoUrl}" "${targetDir}"`, projectsDir);
|
||||||
|
steps.push('Projeto clonado em /home/projetos/');
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'Importado',
|
||||||
|
repoName,
|
||||||
|
repoUrl: `https://git.${DOMAIN}/${GITEA_USER}/${repoName}`,
|
||||||
|
projectDir: targetDir,
|
||||||
|
steps,
|
||||||
|
message: 'Projeto disponível no Gitea e pronto para uso com agentes',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||||
|
res.status(500).json({ error: err.message, steps });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
Reference in New Issue
Block a user