#!/usr/bin/env node
/**
* Web UI for managing discovered and imported git repositories
*/
import { renderPage } from './page-layout.js'
function timeAgoServer (ts) {
const s = Math.floor((Date.now() - ts) / 1000)
if (s < 60) return 'just now'
const m = Math.floor(s / 60)
if (m < 60) return m + 'm ago'
const h = Math.floor(m / 60)
if (h < 24) return h + 'h ago'
const d = Math.floor(h / 24)
if (d < 30) return d + 'd ago'
const mo = Math.floor(d / 30)
if (mo < 12) return mo + 'mo ago'
return Math.floor(mo / 12) + 'y ago'
}
function renderActivityGraph (data) {
if (!data || !data.length) return ''
const max = Math.max(...data, 1)
const w = 360
const h = 32
const pad = 1
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w
const y = pad + (h - 2 * pad) * (1 - v / max)
return `${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
// Fill area under line
const fillPoints = `0,${h} ${points} ${w},${h}`
return `<svg width="${w}" height="${h}" style="vertical-align:middle;">` +
`<polygon points="${fillPoints}" fill="#238636" opacity="0.15"/>` +
`<polyline points="${points}" fill="none" stroke="#238636" stroke-width="1.5" stroke-linejoin="round"/>` +
`</svg>`
}
// Helper function to render a repo card
function renderRepoCard(repo, namespace) {
const status = ''
// Show display name if different from repo name
const displayName = repo.displayName || repo.name
const nameDisplay = displayName !== repo.name
? `${displayName} <span style="color: #8b949e; font-size: 14px;">(${repo.name})</span>`
: repo.name
// Show submodule badge if this repo is a submodule
const submoduleBadge = repo.isSubmoduleOf && repo.isSubmoduleOf.length > 0
? `<span class="submodule-badge">📦 submodule</span>`
: ''
// Show linked badge if repo has Autobase overlay
const linkedBadge = repo.linked
? `<span class="badge" style="background: #1f6feb; color: white; margin-left: 8px;">🔗 Linked</span>`
: ''
// No need for namespace badge — repos are already grouped by namespace
const namespaceBadge = ''
const urls = ''
return `
<div class="repo-card">
<div class="repo-info">
<div class="repo-name">
${nameDisplay}
${status}
${submoduleBadge}
${linkedBadge}
${namespaceBadge}
</div>
<div class="repo-path">${repo.path}</div>
<div class="repo-meta">
<span>${repo.commits || 0} commits</span>
<span>${repo.branches || 0} branches</span>
${repo.lastCommit && repo.lastCommit.timestamp ? '<span>' + timeAgoServer(repo.lastCommit.timestamp * 1000) + '</span>' : ''}
${repo.submodules && repo.submodules.length > 0 ? '<span>' + repo.submodules.length + ' submodules</span>' : ''}
</div>
${urls}
</div>
${repo.activity ? '<div style="flex-shrink:0; margin: 0 12px;">' + renderActivityGraph(repo.activity) + '</div>' : ''}
<div class="repo-actions">
<button class="secondary" onclick="location.href=getBasePath()+'/${namespace}/${repo.name}'">View</button>
</div>
</div>
`
}
// Group repos by namespace (preferred) or category (fallback)
function groupReposSmart(repoList, groups = []) {
const grouped = new Map()
const groupLookup = new Map(groups.map(g => [g.name, g]))
for (const repo of repoList) {
// Prefer groupNamespace over category
const group = repo.groupNamespace || repo.category || 'ungrouped'
if (!grouped.has(group)) {
const meta = groupLookup.get(group)
grouped.set(group, { displayName: meta?.displayName || group, repos: [] })
}
grouped.get(group).repos.push(repo)
}
// Convert to array and sort (groups with displayNames first, then alphabetical)
const result = []
for (const [category, data] of grouped.entries()) {
data.repos.sort((a, b) => (a.displayName || a.name).localeCompare(b.displayName || b.name))
result.push({ category, displayName: data.displayName, repos: data.repos })
}
return result.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
// Helper function to render a category/group section
function renderCategorySection(category, displayName, categoryRepos, namespace, groups, pathPrefix) {
const safeCat = category.replace(/[^a-zA-Z0-9-_]/g, '_')
// Check if this category is a group namespace — make title a link
const isGroup = groups && groups.some(g => g.name === category)
const titleHtml = isGroup
? `<a href="${pathPrefix}/groups/${category}" class="category-title" style="color: inherit; text-decoration: none;">${displayName}</a>`
: `<div class="category-title">${displayName}</div>`
return `
<div class="category-section">
<div class="category-header" onclick="toggleCategory('${safeCat}')">
<span class="category-icon collapsed" id="icon-${safeCat}">▼</span>
${titleHtml}
<div class="category-count">${categoryRepos.length} repo${categoryRepos.length !== 1 ? 's' : ''}</div>
</div>
<div class="category-repos collapsed" id="repos-${safeCat}">
${categoryRepos.map(repo => renderRepoCard(repo, namespace)).join('')}
</div>
</div>
`
}
export function renderRepoManager(repos, stats, namespace = 'main', pathPrefix = '', groups = []) {
// Sort all repos by most recent activity
const sortedRepos = [...repos].sort((a, b) => {
const aTime = (a.lastCommit && a.lastCommit.timestamp) || 0
const bTime = (b.lastCommit && b.lastCommit.timestamp) || 0
return bTime - aTime
})
const PAGE_CSS = `
.actions {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
button {
background: #238636;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
button:hover { background: #2ea043; }
button.secondary { background: #21262d; border: 1px solid #30363d; }
button.secondary:hover { background: #30363d; }
button.danger { background: #da3633; }
button.danger:hover { background: #f85149; }
.tabs {
display: flex;
gap: 5px;
border-bottom: 1px solid #30363d;
margin-bottom: 20px;
}
.tab {
padding: 10px 20px;
background: transparent;
border-radius: 6px 6px 0 0;
cursor: pointer;
}
.tab.active {
background: #161b22;
border: 1px solid #30363d;
border-bottom: none;
}
.repo-list { display: flex; flex-direction: column; gap: 4px; }
.category-section {
margin-bottom: 16px;
}
.category-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #30363d;
margin-bottom: 6px;
cursor: pointer;
user-select: none;
}
.category-header:hover {
color: #58a6ff;
}
.category-icon {
font-size: 20px;
transition: transform 0.2s;
}
.category-icon.collapsed {
transform: rotate(-90deg);
}
.category-title {
font-size: 20px;
font-weight: 600;
flex: 1;
}
.category-count {
color: #8b949e;
font-size: 14px;
}
.category-repos {
display: flex;
flex-direction: column;
gap: 4px;
}
.category-repos.collapsed {
display: none;
}
.submodule-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
background: #6e7681;
color: white;
margin-left: 8px;
}
.repo-card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.repo-info { flex: 1; }
.repo-name {
font-size: 15px;
font-weight: 600;
color: #58a6ff;
margin-bottom: 3px;
}
.repo-path { color: #8b949e; font-size: 14px; margin-bottom: 5px; }
.repo-meta {
display: flex;
gap: 15px;
font-size: 12px;
color: #8b949e;
}
.repo-actions {
display: flex;
gap: 10px;
}
.repo-actions button {
padding: 6px 12px;
font-size: 12px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.badge.imported { background: #1f6feb; color: white; }
.badge.local { background: #6e7681; color: white; }
.code {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 15px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 12px;
margin-top: 10px;
display: none;
}
.code.visible { display: block; }
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.visible { display: flex; }
.modal-content {
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 30px;
max-width: 600px;
width: 90%;
}
.modal-header {
font-size: 24px;
margin-bottom: 20px;
}
.modal-body { margin-bottom: 20px; }
.modal-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
}
/* Add Repo Modal */
.add-repo-tabs {
display: flex;
gap: 5px;
border-bottom: 1px solid #30363d;
margin-bottom: 20px;
}
.add-repo-tab {
padding: 8px 16px;
background: transparent;
border: 1px solid transparent;
border-bottom: none;
border-radius: 6px 6px 0 0;
cursor: pointer;
color: #8b949e;
font-size: 14px;
}
.add-repo-tab.active {
background: #161b22;
border-color: #30363d;
color: #c9d1d9;
}
.form-input {
padding: 8px 12px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #58a6ff;
}
.form-label {
display: block;
color: #8b949e;
font-size: 13px;
margin-bottom: 6px;
}
.scan-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid #30363d;
border-radius: 6px;
margin-bottom: 8px;
background: #161b22;
}
.scan-item:hover { border-color: #58a6ff; }
.scan-item-info { flex: 1; min-width: 0; }
.scan-item-name { color: #58a6ff; font-weight: 500; }
.scan-item-path { color: #8b949e; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scan-item-meta { color: #8b949e; font-size: 12px; margin-top: 2px; }
.scan-item-badge {
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
background: #6e7681;
color: white;
white-space: nowrap;
}
.scan-item-badge.bare { background: #238636; }
`
const content = `
<div class="actions" id="admin-actions" style="display: none;">
<button onclick="quickRescan()">🔄 Rescan</button>
<button onclick="openAddRepoModal()">+ Add Repository</button>
</div>
<div class="tabs">
<div class="tab active" onclick="switchTab('all')">All Repos</div>
</div>
<div class="repo-list" id="repo-list">
${sortedRepos.map(repo => renderRepoCard(repo, namespace, pathPrefix)).join('')}
</div>
</div>
<div id="modal" class="modal">
<div class="modal-content">
<div class="modal-header" id="modal-header">Modal Title</div>
<div class="modal-body" id="modal-body">Modal content</div>
<div class="modal-footer">
<button class="secondary" onclick="closeModal()">Cancel</button>
<button id="modal-confirm" onclick="confirmModal()">Confirm</button>
</div>
</div>
</div>
<!-- Add Repository Modal -->
<div id="add-repo-modal" class="modal">
<div class="modal-content" style="max-width: 700px;">
<div class="modal-header">
<span>Add Repository</span>
<button class="secondary" onclick="closeAddRepoModal()" style="float: right; padding: 4px 10px; font-size: 18px; line-height: 1;">×</button>
</div>
<div class="add-repo-tabs">
<button class="add-repo-tab active" data-tab="scan" onclick="switchAddRepoTab('scan')">Scan Directory</button>
<button class="add-repo-tab" data-tab="manual" onclick="switchAddRepoTab('manual')">Manual Import</button>
</div>
<!-- Scan Tab -->
<div class="add-repo-panel" id="add-repo-scan">
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
<input type="text" id="scan-path" class="form-input" placeholder="/home/user/projects" style="flex: 1;">
<select id="scan-depth" class="form-input" style="width: 100px;">
<option value="1">Depth 1</option>
<option value="2" selected>Depth 2</option>
<option value="3">Depth 3</option>
</select>
<button onclick="scanDirectory()" id="scan-btn">Scan</button>
</div>
<div id="scan-status" style="display: none; margin-bottom: 10px; color: #8b949e; font-size: 14px;"></div>
<div id="scan-results" style="max-height: 350px; overflow-y: auto;"></div>
<div id="scan-actions" style="display: none; margin-top: 15px; gap: 10px; justify-content: space-between; align-items: center;">
<label style="color: #8b949e; font-size: 14px; cursor: pointer;">
<input type="checkbox" id="scan-select-all" onchange="toggleSelectAll(this.checked)"> Select all
</label>
<button onclick="importSelected()" id="import-selected-btn" disabled>Import Selected (0)</button>
</div>
</div>
<!-- Manual Tab -->
<div class="add-repo-panel" id="add-repo-manual" style="display: none;">
<div style="margin-bottom: 15px;">
<label class="form-label">Repository Name</label>
<input type="text" id="manual-name" class="form-input" placeholder="my-project">
</div>
<div style="margin-bottom: 15px;">
<label class="form-label">Source (local path or clone URL)</label>
<input type="text" id="manual-source" class="form-input" placeholder="/home/user/my-project or https://github.com/user/repo.git" oninput="autoFillName(this.value)">
</div>
<div style="margin-bottom: 15px;">
<label class="form-label">Visibility</label>
<select id="manual-visibility" class="form-input">
<option value="public">Public</option>
<option value="private">Private</option>
</select>
</div>
<div id="manual-status" style="display: none; margin-bottom: 10px; font-size: 14px;"></div>
<div style="text-align: right;">
<button onclick="importManual()" id="manual-import-btn">Import</button>
</div>
</div>
</div>
</div>
`
const scripts = `
let currentTab = 'all';
let repos = ${JSON.stringify(repos)};
let namespace = ${JSON.stringify(namespace)};
let userPermissions = null;
// Use server-injected path prefix from identity script
function getBasePath() {
return window.__PATH_PREFIX__ || '';
}
// Show admin actions if user has admin permission
async function loadAdminActions() {
if (window.identityReady) {
await window.identityReady;
}
try {
const response = await window.fetch(getBasePath() + '/api/user/identities');
if (!response.ok) return;
const data = await response.json();
userPermissions = data.permissions;
const hasAdmin = data.permissions.global.includes('admin') ||
data.permissions.repos.some(r => r.permissions.includes('admin'));
if (hasAdmin) {
document.getElementById('admin-actions').style.display = 'flex';
}
} catch (err) {
console.error('Failed to load user identity:', err);
}
}
loadAdminActions();
function timeAgo(ts) {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 60) return 'just now';
const m = Math.floor(s / 60);
if (m < 60) return m + 'm ago';
const h = Math.floor(m / 60);
if (h < 24) return h + 'h ago';
const d = Math.floor(h / 24);
if (d < 30) return d + 'd ago';
const mo = Math.floor(d / 30);
if (mo < 12) return mo + 'mo ago';
return Math.floor(mo / 12) + 'y ago';
}
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
if (event && event.target) event.target.classList.add('active');
renderRepos();
}
const groupNames = ${JSON.stringify(Object.fromEntries(groups.map(g => [g.name, g.displayName || g.name])))};
function groupReposSmart(repoList) {
const grouped = new Map();
for (const repo of repoList) {
const group = repo.groupNamespace || repo.category || 'ungrouped';
if (!grouped.has(group)) {
grouped.set(group, { displayName: groupNames[group] || group, repos: [] });
}
grouped.get(group).repos.push(repo);
}
const result = [];
for (const [category, data] of grouped.entries()) {
// Sort repos by most recent commit first
data.repos.sort((a, b) => {
const aTime = (a.lastCommit && a.lastCommit.timestamp) || 0;
const bTime = (b.lastCommit && b.lastCommit.timestamp) || 0;
return bTime - aTime;
});
const newest = data.repos[0];
const groupTime = (newest && newest.lastCommit && newest.lastCommit.timestamp) || 0;
result.push({ category, displayName: data.displayName, repos: data.repos, lastActivity: groupTime });
}
// Sort groups by most recent activity first
return result.sort((a, b) => b.lastActivity - a.lastActivity);
}
function renderRepoCard(repo, ns) {
const displayName = repo.displayName || repo.name;
const nameDisplay = displayName !== repo.name
? displayName + ' <span style="color: #8b949e; font-size: 14px;">(' + repo.name + ')</span>'
: repo.name;
const status = repo.imported
? '<span class="badge imported">Imported</span>'
: '<span class="badge local">Local Only</span>';
const linkedBadge = repo.linked
? '<span class="badge" style="background: #1f6feb; color: white; margin-left: 8px;">Linked</span>'
: '';
return \`
<div class="repo-card">
<div class="repo-info">
<div class="repo-name">\${nameDisplay} \${status} \${linkedBadge}</div>
<div class="repo-path">\${repo.path || ''}</div>
<div class="repo-meta">
<span>\${repo.commits || 0} commits</span>
<span>\${repo.branches || 0} branches</span>
\${repo.lastCommit && repo.lastCommit.timestamp ? '<span>' + timeAgo(repo.lastCommit.timestamp * 1000) + '</span>' : ''}
</div>
</div>
<div class="repo-actions">
\${repo.imported
? '<button class="secondary" onclick="toggleUrls(\\'' + repo.name + '\\')">URLs</button>'
: '<button onclick="importRepo(\\'' + repo.name + '\\')">Import</button>'
}
<button class="secondary" onclick="location.href=getBasePath()+'/\${ns}/\${repo.name}'">View</button>
</div>
</div>
\`;
}
function renderCategorySection(category, displayName, categoryRepos) {
const safeCat = category.replace(/[^a-zA-Z0-9-_]/g, '_');
const isGroup = groupNames.hasOwnProperty(category);
const titleHtml = isGroup
? '<a href="' + getBasePath() + '/groups/' + category + '" class="category-title" style="color: inherit; text-decoration: none;">' + displayName + '</a>'
: '<div class="category-title">' + displayName + '</div>';
return \`
<div class="category-section">
<div class="category-header" onclick="toggleCategory('\${safeCat}')">
<span class="category-icon collapsed" id="icon-\${safeCat}">▼</span>
\${titleHtml}
<div class="category-count">\${categoryRepos.length} repo\${categoryRepos.length !== 1 ? 's' : ''}</div>
</div>
<div class="category-repos collapsed" id="repos-\${safeCat}">
\${categoryRepos.map(repo => renderRepoCard(repo, namespace)).join('')}
</div>
</div>
\`;
}
function toggleCategory(category) {
const icon = document.getElementById('icon-' + category);
const repos = document.getElementById('repos-' + category);
if (repos.classList.contains('collapsed')) {
repos.classList.remove('collapsed');
icon.classList.remove('collapsed');
} else {
repos.classList.add('collapsed');
icon.classList.add('collapsed');
}
}
function renderRepos() {
// Sort all repos flat by most recent activity
const sorted = [...repos].sort((a, b) => {
const aTime = (a.lastCommit && a.lastCommit.timestamp) || 0;
const bTime = (b.lastCommit && b.lastCommit.timestamp) || 0;
return bTime - aTime;
});
document.getElementById('repo-list').innerHTML =
sorted.map(repo => renderRepoCard(repo, namespace)).join('');
}
async function quickRescan() {
try {
const btn = event.target;
btn.disabled = true;
btn.textContent = '🔄 Scanning...';
const response = await window.fetch(getBasePath() + '/api/fast-scan');
const result = await response.json();
if (result.success) {
// Reload page to show new repos
window.location.reload();
} else {
alert('Scan failed: ' + (result.error || 'Unknown error'));
btn.disabled = false;
btn.textContent = '🔄 Rescan Repos';
}
} catch (err) {
alert('Scan failed: ' + err.message);
event.target.disabled = false;
event.target.textContent = '🔄 Rescan Repos';
}
}
function toggleUrls(name) {
const el = document.getElementById('urls-' + name);
el.classList.toggle('visible');
}
function closeModal() {
document.getElementById('modal').classList.remove('visible');
}
function confirmModal() {
closeModal();
}
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// ========================================
// Add Repository Modal
// ========================================
let scannedRepos = [];
let selectedRepos = new Set();
function openAddRepoModal() {
document.getElementById('add-repo-modal').classList.add('visible');
scannedRepos = [];
selectedRepos.clear();
document.getElementById('scan-results').innerHTML = '';
document.getElementById('scan-status').style.display = 'none';
document.getElementById('scan-actions').style.display = 'none';
document.getElementById('manual-status').style.display = 'none';
}
function closeAddRepoModal() {
document.getElementById('add-repo-modal').classList.remove('visible');
}
function switchAddRepoTab(tab) {
document.querySelectorAll('.add-repo-tab').forEach(t => {
t.classList.toggle('active', t.dataset.tab === tab);
});
document.getElementById('add-repo-scan').style.display = tab === 'scan' ? 'block' : 'none';
document.getElementById('add-repo-manual').style.display = tab === 'manual' ? 'block' : 'none';
}
async function scanDirectory() {
const scanPath = document.getElementById('scan-path').value.trim();
if (!scanPath) { alert('Enter a directory path'); return; }
const depth = parseInt(document.getElementById('scan-depth').value);
const btn = document.getElementById('scan-btn');
const status = document.getElementById('scan-status');
const results = document.getElementById('scan-results');
const actions = document.getElementById('scan-actions');
btn.disabled = true;
btn.textContent = 'Scanning...';
status.style.display = 'block';
status.textContent = 'Scanning ' + escapeHtml(scanPath) + '...';
status.style.color = '#8b949e';
results.innerHTML = '';
actions.style.display = 'none';
selectedRepos.clear();
try {
const response = await window.fetch(getBasePath() + '/api/admin/repos/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: scanPath, maxDepth: depth })
});
const data = await response.json();
if (!response.ok) {
status.textContent = 'Error: ' + (data.error || 'Scan failed');
status.style.color = '#f85149';
return;
}
scannedRepos = data.repos || [];
const total = data.total || 0;
const filtered = data.filtered || scannedRepos.length;
if (scannedRepos.length === 0) {
status.textContent = total > 0
? 'Found ' + total + ' repos, but all are already registered.'
: 'No git repositories found in this directory.';
return;
}
status.textContent = 'Found ' + scannedRepos.length + ' new repo' + (scannedRepos.length !== 1 ? 's' : '') +
(total > filtered ? ' (' + (total - filtered) + ' already registered)' : '');
results.innerHTML = scannedRepos.map(function(repo, i) {
return '<div class="scan-item">' +
'<input type="checkbox" data-idx="' + i + '" onchange="toggleScanSelect(' + i + ', this.checked)">' +
'<div class="scan-item-info">' +
'<div class="scan-item-name">' + escapeHtml(repo.name) + '</div>' +
'<div class="scan-item-path">' + escapeHtml(repo.path) + '</div>' +
'<div class="scan-item-meta">' + repo.commits + ' commits · ' + repo.branches + ' branches</div>' +
'</div>' +
'<span class="scan-item-badge' + (repo.isBare ? ' bare' : '') + '">' + (repo.isBare ? 'bare' : 'regular') + '</span>' +
'</div>';
}).join('');
actions.style.display = 'flex';
updateImportButton();
} catch (err) {
status.textContent = 'Error: ' + err.message;
status.style.color = '#f85149';
} finally {
btn.disabled = false;
btn.textContent = 'Scan';
}
}
function toggleScanSelect(idx, checked) {
if (checked) {
selectedRepos.add(idx);
} else {
selectedRepos.delete(idx);
}
updateImportButton();
// Update select-all checkbox
var selectAll = document.getElementById('scan-select-all');
if (selectAll) selectAll.checked = selectedRepos.size === scannedRepos.length;
}
function toggleSelectAll(checked) {
var checkboxes = document.querySelectorAll('#scan-results input[type=checkbox]');
checkboxes.forEach(function(cb) {
cb.checked = checked;
var idx = parseInt(cb.dataset.idx);
if (checked) selectedRepos.add(idx);
else selectedRepos.delete(idx);
});
updateImportButton();
}
function updateImportButton() {
var btn = document.getElementById('import-selected-btn');
var count = selectedRepos.size;
btn.textContent = 'Import Selected (' + count + ')';
btn.disabled = count === 0;
}
async function importSelected() {
if (selectedRepos.size === 0) return;
var btn = document.getElementById('import-selected-btn');
var status = document.getElementById('scan-status');
btn.disabled = true;
var toImport = Array.from(selectedRepos).map(function(i) { return scannedRepos[i]; });
var succeeded = 0;
var failed = 0;
for (var i = 0; i < toImport.length; i++) {
var repo = toImport[i];
status.textContent = 'Importing ' + (i + 1) + '/' + toImport.length + ': ' + repo.name + '...';
try {
var response = await window.fetch(getBasePath() + '/api/admin/repos/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: repo.name, fromPath: repo.path })
});
var data = await response.json();
if (response.ok && data.success) {
succeeded++;
} else {
failed++;
console.error('Import failed for', repo.name, ':', data.error);
}
} catch (err) {
failed++;
console.error('Import failed for', repo.name, ':', err.message);
}
}
status.textContent = 'Done! Imported ' + succeeded + ' repo' + (succeeded !== 1 ? 's' : '') +
(failed > 0 ? ', ' + failed + ' failed' : '') + '.';
status.style.color = failed > 0 ? '#d29922' : '#3fb950';
if (succeeded > 0) {
setTimeout(function() { window.location.reload(); }, 1500);
} else {
btn.disabled = false;
}
}
function autoFillName(source) {
var nameField = document.getElementById('manual-name');
if (nameField.value) return; // Don't overwrite if user typed something
// Extract name from path or URL
var match = source.match(/\\/([^/]+?)(\\.git)?\\/?$/);
if (match) {
nameField.value = match[1];
}
}
async function importManual() {
var name = document.getElementById('manual-name').value.trim();
var source = document.getElementById('manual-source').value.trim();
var visibility = document.getElementById('manual-visibility').value;
var status = document.getElementById('manual-status');
var btn = document.getElementById('manual-import-btn');
if (!name) { alert('Enter a repository name'); return; }
if (!source) { alert('Enter a source path or URL'); return; }
btn.disabled = true;
btn.textContent = 'Importing...';
status.style.display = 'block';
status.textContent = 'Importing ' + name + '...';
status.style.color = '#8b949e';
// Detect if source is a URL or path
var isUrl = /^(https?:\\/\\/|git:\\/\\/|ssh:\\/\\/|[a-zA-Z0-9._-]+@)/.test(source);
var body = { name: name, visibility: visibility };
if (isUrl) {
body.cloneUrl = source;
} else {
body.fromPath = source;
}
try {
var response = await window.fetch(getBasePath() + '/api/admin/repos/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
var data = await response.json();
if (response.ok && data.success) {
status.textContent = 'Imported ' + name + ' successfully!' +
(data.punchUrl ? ' P2P: ' + data.punchUrl : '');
status.style.color = '#3fb950';
setTimeout(function() { window.location.reload(); }, 1500);
} else {
status.textContent = 'Error: ' + (data.error || 'Import failed');
status.style.color = '#f85149';
btn.disabled = false;
btn.textContent = 'Import';
}
} catch (err) {
status.textContent = 'Error: ' + err.message;
status.style.color = '#f85149';
btn.disabled = false;
btn.textContent = 'Import';
}
}
`
return renderPage('Repository Manager', content, { pathPrefix, activePage: 'repos', css: PAGE_CSS, scripts })
}