Adicionar painel de importação de projetos para o Gitea
Novo menu "Importar" que permite selecionar um diretório do servidor, navegar pela árvore de pastas, criar um repositório no Gitea e copiar os arquivos respeitando .gitignore, sem alterar o projeto original.
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="import">
|
||||||
|
<i data-lucide="upload-cloud"></i>
|
||||||
|
<span>Importar</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="sidebar-nav-item">
|
<li class="sidebar-nav-item">
|
||||||
<a href="#" class="sidebar-nav-link" data-section="files">
|
<a href="#" class="sidebar-nav-link" data-section="files">
|
||||||
<i data-lucide="folder-open"></i>
|
<i data-lucide="folder-open"></i>
|
||||||
@@ -584,6 +590,10 @@
|
|||||||
<div id="history-pagination"></div>
|
<div id="history-pagination"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="import" class="section" aria-label="Importar Projeto" hidden>
|
||||||
|
<div id="import-container"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="files" class="section" aria-label="Projetos" hidden>
|
<section id="files" class="section" aria-label="Projetos" hidden>
|
||||||
<div id="files-container"></div>
|
<div id="files-container"></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1435,6 +1445,7 @@
|
|||||||
<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/components/files.js"></script>
|
||||||
|
<script src="js/components/import.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
<script>
|
<script>
|
||||||
Utils.refreshIcons();
|
Utils.refreshIcons();
|
||||||
|
|||||||
@@ -5358,3 +5358,160 @@ body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metr
|
|||||||
width: 70px;
|
width: 70px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-card .card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-desc code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
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;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-select-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-repos-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-repo-card {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-repo-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-repo-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-repo-name {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-repo-name:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-repo-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-repo-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-repo-meta span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|||||||
@@ -149,6 +149,11 @@ const API = {
|
|||||||
branches(name) { return API.request('GET', `/repos/${encodeURIComponent(name)}/branches`); },
|
branches(name) { return API.request('GET', `/repos/${encodeURIComponent(name)}/branches`); },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
projects: {
|
||||||
|
browse(path) { return API.request('GET', `/browse?path=${encodeURIComponent(path || '/home')}`); },
|
||||||
|
import(sourcePath, repoName) { return API.request('POST', '/projects/import', { sourcePath, repoName }); },
|
||||||
|
},
|
||||||
|
|
||||||
files: {
|
files: {
|
||||||
list(path) { return API.request('GET', `/files${path ? '?path=' + encodeURIComponent(path) : ''}`); },
|
list(path) { return API.request('GET', `/files${path ? '?path=' + encodeURIComponent(path) : ''}`); },
|
||||||
delete(path) { return API.request('DELETE', `/files?path=${encodeURIComponent(path)}`); },
|
delete(path) { return API.request('DELETE', `/files?path=${encodeURIComponent(path)}`); },
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ const App = {
|
|||||||
webhooks: 'Webhooks',
|
webhooks: 'Webhooks',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
history: 'Histórico',
|
history: 'Histórico',
|
||||||
|
import: 'Importar Projeto',
|
||||||
files: 'Projetos',
|
files: 'Projetos',
|
||||||
settings: 'Configurações',
|
settings: 'Configurações',
|
||||||
},
|
},
|
||||||
|
|
||||||
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'files', 'settings'],
|
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'import', 'files', 'settings'],
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if (App._initialized) return;
|
if (App._initialized) return;
|
||||||
@@ -115,6 +116,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 'import': await ImportUI.load(); break;
|
||||||
case 'files': await FilesUI.load(); break;
|
case 'files': await FilesUI.load(); break;
|
||||||
case 'settings': await SettingsUI.load(); break;
|
case 'settings': await SettingsUI.load(); break;
|
||||||
}
|
}
|
||||||
|
|||||||
281
public/js/components/import.js
Normal file
281
public/js/components/import.js
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
const ImportUI = {
|
||||||
|
_currentBrowsePath: '/home',
|
||||||
|
_selectedPath: '',
|
||||||
|
_importing: false,
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const container = document.getElementById('import-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let repos = [];
|
||||||
|
try {
|
||||||
|
repos = await API.repos.list();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="import-layout">
|
||||||
|
<div class="card import-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title"><i data-lucide="upload-cloud" style="width:20px;height:20px"></i> Importar Projeto</h2>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Diretório do projeto</label>
|
||||||
|
<div class="import-path-row">
|
||||||
|
<input type="text" class="form-input" id="import-path" placeholder="/home/fred/meu-projeto" value="" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="import-browser" class="import-browser" hidden>
|
||||||
|
<div class="import-browser-header">
|
||||||
|
<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">
|
||||||
|
<label class="form-label">Nome do repositório no Gitea</label>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary" id="import-submit-btn" type="button">
|
||||||
|
<i data-lucide="upload-cloud" style="width:16px;height:16px"></i> Importar para o Gitea
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title"><i data-lucide="git-branch" style="width:20px;height:20px"></i> Repositórios no Gitea</h2>
|
||||||
|
<span class="badge badge--accent">${repos.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
${repos.length === 0 ? '<p class="text-muted">Nenhum repositório encontrado</p>' : ''}
|
||||||
|
<div class="import-repos-grid">
|
||||||
|
${repos.map(r => ImportUI._renderRepoCard(r)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Utils.refreshIcons(container);
|
||||||
|
ImportUI._bindEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderRepoCard(repo) {
|
||||||
|
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) : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="import-repo-card">
|
||||||
|
<div class="import-repo-header">
|
||||||
|
<i data-lucide="git-branch" style="width:16px;height:16px;color:var(--accent)"></i>
|
||||||
|
<a href="${Utils.escapeHtml(repoUrl)}" target="_blank" class="import-repo-name">${Utils.escapeHtml(repo.name)}</a>
|
||||||
|
</div>
|
||||||
|
${repo.description ? `<p class="import-repo-desc">${Utils.escapeHtml(repo.description)}</p>` : ''}
|
||||||
|
<div class="import-repo-meta">
|
||||||
|
${updated ? `<span><i data-lucide="calendar" style="width:12px;height:12px"></i> ${updated}</span>` : ''}
|
||||||
|
${size ? `<span><i data-lucide="hard-drive" style="width:12px;height:12px"></i> ${size}</span>` : ''}
|
||||||
|
${repo.default_branch ? `<span><i data-lucide="git-commit" style="width:12px;height:12px"></i> ${Utils.escapeHtml(repo.default_branch)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_formatSize(bytes) {
|
||||||
|
if (!bytes) return '';
|
||||||
|
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];
|
||||||
|
},
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
const browseBtn = document.getElementById('import-browse-btn');
|
||||||
|
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 (submitBtn) {
|
||||||
|
submitBtn.addEventListener('click', () => ImportUI._doImport());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_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, '-');
|
||||||
|
},
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_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 = `<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 {
|
||||||
|
listEl.innerHTML = dirs.map(d => `
|
||||||
|
<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;
|
||||||
|
ImportUI._selectedPath = selectedPath;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async _doImport() {
|
||||||
|
if (ImportUI._importing) 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; }
|
||||||
|
if (!repoName) { Toast.warning('Informe o nome do repositório'); return; }
|
||||||
|
|
||||||
|
ImportUI._importing = true;
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px" class="spin"></i> Importando...';
|
||||||
|
Utils.refreshIcons(submitBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Toast.info('Importando projeto... isso pode levar alguns segundos');
|
||||||
|
const result = await API.projects.import(sourcePath, repoName);
|
||||||
|
|
||||||
|
Toast.success('Projeto importado com sucesso!');
|
||||||
|
|
||||||
|
const modal = document.getElementById('execution-detail-modal-overlay');
|
||||||
|
const title = document.getElementById('execution-detail-title');
|
||||||
|
const content = document.getElementById('execution-detail-content');
|
||||||
|
if (modal && title && content) {
|
||||||
|
title.textContent = 'Projeto Importado';
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="publish-result">
|
||||||
|
<div class="publish-result-item"><strong>Repositório:</strong> <a href="${Utils.escapeHtml(result.repoUrl)}" target="_blank">${Utils.escapeHtml(result.repoUrl)}</a></div>
|
||||||
|
<div class="publish-result-item"><strong>Diretório:</strong> <code>${Utils.escapeHtml(result.projectDir)}</code></div>
|
||||||
|
<div class="publish-result-item"><strong>Status:</strong> <span class="badge badge-active">${Utils.escapeHtml(result.status)}</span></div>
|
||||||
|
${result.message ? `<div class="publish-result-item"><em>${Utils.escapeHtml(result.message)}</em></div>` : ''}
|
||||||
|
<div class="publish-result-steps">
|
||||||
|
<strong>Passos:</strong>
|
||||||
|
<ul>${(result.steps || []).map(s => `<li>${Utils.escapeHtml(s)}</li>`).join('')}</ul>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
Modal.open('execution-detail-modal-overlay');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathInput) pathInput.value = '';
|
||||||
|
if (nameInput) nameInput.value = '';
|
||||||
|
App._reposCache = null;
|
||||||
|
await ImportUI.load();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(`Erro ao importar: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
ImportUI._importing = false;
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<i data-lucide="upload-cloud" style="width:16px;height:16px"></i> Importar para o Gitea';
|
||||||
|
Utils.refreshIcons(submitBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.ImportUI = ImportUI;
|
||||||
@@ -1322,4 +1322,138 @@ router.post('/files/publish', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/browse', (req, res) => {
|
||||||
|
const requestedPath = req.query.path || '/home';
|
||||||
|
const resolved = pathResolve(requestedPath);
|
||||||
|
|
||||||
|
const blocked = ['/proc', '/sys', '/dev', '/boot', '/run'];
|
||||||
|
if (blocked.some(b => resolved.startsWith(b))) {
|
||||||
|
return res.status(403).json({ error: 'Acesso negado a este diretório' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(resolved, { withFileTypes: true });
|
||||||
|
const dirs = entries
|
||||||
|
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
||||||
|
.map(e => ({ name: e.name, path: join(resolved, e.name) }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
currentPath: resolved,
|
||||||
|
parentPath: dirname(resolved),
|
||||||
|
directories: dirs,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: `Erro ao listar: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/projects/import', async (req, res) => {
|
||||||
|
const { sourcePath, repoName } = req.body;
|
||||||
|
if (!sourcePath || !repoName) return res.status(400).json({ error: 'sourcePath e repoName são obrigatórios' });
|
||||||
|
|
||||||
|
const resolvedSource = pathResolve(sourcePath);
|
||||||
|
if (!existsSync(resolvedSource)) return res.status(400).json({ error: 'Diretório não encontrado' });
|
||||||
|
if (!statSync(resolvedSource).isDirectory()) return res.status(400).json({ error: 'Caminho não é um diretório' });
|
||||||
|
|
||||||
|
const sanitizedName = repoName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
|
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(), `import-${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 {
|
||||||
|
const authHeader = 'Basic ' + Buffer.from(`${GITEA_USER}:${GITEA_PASS}`).toString('base64');
|
||||||
|
const repoApiUrl = `${GITEA_URL}/api/v1/repos/${GITEA_USER}/${sanitizedName}`;
|
||||||
|
|
||||||
|
let repoExists = false;
|
||||||
|
try {
|
||||||
|
const check = await fetch(repoApiUrl, { 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: sanitizedName, auto_init: false, private: false }),
|
||||||
|
});
|
||||||
|
if (!createRes.ok) {
|
||||||
|
const err = await createRes.json().catch(() => ({}));
|
||||||
|
throw new Error(`Erro ao criar repositório: ${err.message || createRes.statusText}`);
|
||||||
|
}
|
||||||
|
steps.push('Repositório criado no Gitea');
|
||||||
|
} else {
|
||||||
|
steps.push('Repositório já existe no Gitea');
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(tmpDir, { recursive: true });
|
||||||
|
|
||||||
|
const hasGit = existsSync(join(resolvedSource, '.git'));
|
||||||
|
if (hasGit) {
|
||||||
|
try {
|
||||||
|
await exec(`git -C "${resolvedSource}" archive HEAD | tar -x -C "${tmpDir}"`, resolvedSource);
|
||||||
|
steps.push('Arquivos exportados via git archive (respeitando .gitignore)');
|
||||||
|
} catch {
|
||||||
|
await exec(`rsync -a --exclude='.git' --exclude='node_modules' --exclude='__pycache__' --exclude='.env' "${resolvedSource}/" "${tmpDir}/"`);
|
||||||
|
steps.push('Arquivos copiados via rsync (git archive falhou, possivelmente sem commits)');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const hasGitignore = existsSync(join(resolvedSource, '.gitignore'));
|
||||||
|
let rsyncCmd = `rsync -a --exclude='.git' --exclude='node_modules' --exclude='__pycache__' --exclude='.env'`;
|
||||||
|
if (hasGitignore) rsyncCmd += ` --filter=':- .gitignore'`;
|
||||||
|
rsyncCmd += ` "${resolvedSource}/" "${tmpDir}/"`;
|
||||||
|
await exec(rsyncCmd, resolvedSource);
|
||||||
|
steps.push(hasGitignore ? 'Arquivos copiados respeitando .gitignore' : 'Arquivos copiados (sem .gitignore encontrado)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoUrl = `${GITEA_URL.replace('://', `://${GITEA_USER}:${GITEA_PASS}@`)}/${GITEA_USER}/${sanitizedName}.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 ${sanitizedName}"`);
|
||||||
|
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, sanitizedName);
|
||||||
|
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: sanitizedName,
|
||||||
|
repoUrl: `https://git.${DOMAIN}/${GITEA_USER}/${sanitizedName}`,
|
||||||
|
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