diff --git a/public/css/styles.css b/public/css/styles.css
index bd3feb7..f9742c7 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -5388,71 +5388,46 @@ body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metr
.import-path-row {
display: flex;
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;
- justify-content: space-between;
- padding: 6px 12px;
- border-bottom: 1px solid var(--border-primary);
- transition: background 0.15s;
}
-.import-browser-item:last-child {
- border-bottom: none;
-}
-
-.import-browser-item:hover {
- background: var(--bg-card-hover);
-}
-
-.import-browser-dir {
+.import-folder-display {
+ flex: 1;
display: flex;
align-items: center;
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);
- text-decoration: none;
- font-size: 13px;
- flex: 1;
- min-width: 0;
}
-.import-browser-dir:hover {
- color: var(--accent);
-}
-
-.import-browser-empty {
- padding: 24px;
- text-align: center;
+.import-stat--muted {
color: var(--text-muted);
- font-size: 13px;
-}
-
-.import-select-btn {
- flex-shrink: 0;
+ font-size: 12px;
}
.import-repos-grid {
diff --git a/public/js/api.js b/public/js/api.js
index 211a36b..f0d2c61 100644
--- a/public/js/api.js
+++ b/public/js/api.js
@@ -151,7 +151,21 @@ const API = {
projects: {
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: {
diff --git a/public/js/components/import.js b/public/js/components/import.js
index 78cc150..e08fd75 100644
--- a/public/js/components/import.js
+++ b/public/js/components/import.js
@@ -1,16 +1,18 @@
const ImportUI = {
- _currentBrowsePath: '/home',
- _selectedPath: '',
+ _selectedFiles: [],
+ _selectedPaths: [],
+ _folderName: '',
_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() {
const container = document.getElementById('import-container');
if (!container) return;
let repos = [];
- try {
- repos = await API.repos.list();
- } catch {}
+ try { repos = await API.repos.list(); } catch {}
container.innerHTML = `
@@ -19,26 +21,32 @@ const ImportUI = {
Importar Projeto
-
Selecione um diretório do servidor para importar ao Gitea. Os arquivos serão copiados respeitando o .gitignore, sem alterar o projeto original.
+
Selecione uma pasta do seu computador para enviar ao Gitea. Arquivos ignorados pelo .gitignore e pastas como node_modules serão filtrados automaticamente.
+
+
+
-
+
+
+
Nome do repositório no Gitea
- Letras minúsculas, números e hífens. Será criado no Gitea e clonado em /home/projetos/
+ Letras minúsculas, números e hífens
-
+
+
Importar para o Gitea
@@ -67,7 +75,7 @@ const ImportUI = {
const domain = 'nitro-cloud.duckdns.org';
const repoUrl = `https://git.${domain}/${repo.full_name || repo.name}`;
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 `
@@ -85,7 +93,7 @@ const ImportUI = {
`;
},
- _formatSize(bytes) {
+ _fmtSize(bytes) {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
@@ -93,152 +101,170 @@ const ImportUI = {
},
_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 pathInput = document.getElementById('import-path');
- if (browseBtn) {
- browseBtn.addEventListener('click', () => {
- const browser = document.getElementById('import-browser');
- 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 (selectBtn && folderInput) {
+ selectBtn.addEventListener('click', () => folderInput.click());
+ folderInput.addEventListener('change', () => ImportUI._onFolderSelected(folderInput.files));
}
if (submitBtn) {
- submitBtn.addEventListener('click', () => ImportUI._doImport());
+ submitBtn.addEventListener('click', () => ImportUI._doUpload());
}
},
- _autoFillRepoName(path) {
- const nameInput = document.getElementById('import-repo-name');
- if (!nameInput || nameInput.value.trim()) return;
- const folderName = path.split('/').filter(Boolean).pop() || '';
- nameInput.value = folderName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
+ _shouldExclude(relativePath) {
+ const parts = relativePath.split('/');
+ for (const part of parts.slice(0, -1)) {
+ if (ImportUI._excludedDirs.includes(part)) return true;
+ }
+ const fileName = parts[parts.length - 1];
+ for (const pattern of ImportUI._excludedFiles) {
+ if (pattern.startsWith('*.')) {
+ if (fileName.endsWith(pattern.slice(1))) return true;
+ } else {
+ if (fileName === pattern) return true;
+ }
+ }
+ return false;
},
- 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}`);
+ _parseGitignore(content) {
+ 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;
},
- _renderBrowser(data) {
- const breadcrumbEl = document.getElementById('import-browser-breadcrumb');
- const listEl = document.getElementById('import-browser-list');
- if (!breadcrumbEl || !listEl) return;
-
- const parts = data.currentPath.split('/').filter(Boolean);
- let breadcrumb = `
/`;
- let accumulated = '';
- for (const part of parts) {
- accumulated += '/' + part;
- breadcrumb += `
/ ${Utils.escapeHtml(part)} `;
- }
- breadcrumbEl.innerHTML = breadcrumb;
-
- const dirs = data.directories || [];
- if (dirs.length === 0) {
- listEl.innerHTML = '
Nenhum subdiretório encontrado
';
- } else {
- listEl.innerHTML = dirs.map(d => `
-
- `).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, '-');
+ _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;
}
- document.getElementById('import-browser').hidden = true;
- ImportUI._selectedPath = selectedPath;
- });
- });
+ }
+ }
+ return false;
},
- async _doImport() {
- if (ImportUI._importing) return;
+ _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 = `
+
+
${Utils.escapeHtml(ImportUI._folderName)}
+ `;
+ Utils.refreshIcons(display);
+ }
+
+ const preview = document.getElementById('import-preview');
+ if (preview) {
+ preview.hidden = false;
+ preview.innerHTML = `
+
+
+
+ ${filtered.length} arquivos selecionados
+
+
+
+ ${ImportUI._fmtSize(totalSize)} total
+
+ ${excluded > 0 ? `
+
+ ${excluded} arquivos ignorados (.gitignore / node_modules / etc.)
+
` : ''}
+
+ `;
+ 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 submitBtn = document.getElementById('import-submit-btn');
- const sourcePath = pathInput?.value.trim();
- const repoName = nameInput?.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
-
- if (!sourcePath) { Toast.warning('Informe o caminho do projeto'); return; }
+ const repoName = (nameInput?.value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
if (!repoName) { Toast.warning('Informe o nome do repositório'); return; }
ImportUI._importing = true;
if (submitBtn) {
submitBtn.disabled = true;
- submitBtn.innerHTML = '
Importando...';
+ submitBtn.innerHTML = '
Enviando...';
Utils.refreshIcons(submitBtn);
}
try {
- Toast.info('Importando projeto... isso pode levar alguns segundos');
- const result = await API.projects.import(sourcePath, repoName);
+ Toast.info(`Enviando ${ImportUI._selectedFiles.length} arquivos...`);
+ const result = await API.projects.upload(ImportUI._selectedFiles, ImportUI._selectedPaths, repoName);
Toast.success('Projeto importado com sucesso!');
@@ -261,7 +287,9 @@ const ImportUI = {
Modal.open('execution-detail-modal-overlay');
}
- if (pathInput) pathInput.value = '';
+ ImportUI._selectedFiles = [];
+ ImportUI._selectedPaths = [];
+ ImportUI._folderName = '';
if (nameInput) nameInput.value = '';
App._reposCache = null;
await ImportUI.load();
diff --git a/src/routes/api.js b/src/routes/api.js
index ebcfdd5..fadf94b 100644
--- a/src/routes/api.js
+++ b/src/routes/api.js
@@ -39,6 +39,11 @@ const upload = multer({
limits: { fileSize: 10 * 1024 * 1024, files: 20 },
});
+const importUpload = multer({
+ storage: multer.memoryStorage(),
+ limits: { fileSize: 50 * 1024 * 1024, files: 10000 },
+});
+
const router = 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;