OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / repo-manager-ui.js

935 lines · javascript ref:
#!/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;">&times;</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, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;');
    }

    // ========================================
    // 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 &middot; ' + 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 })
}