UNPKG

git-contextor

Version:

A code context tool with vector search and real-time monitoring, with optional Git integration.

497 lines (421 loc) 21.5 kB
document.addEventListener('DOMContentLoaded', () => { // Helper function to build API URLs - context-aware for tunnel vs direct access function buildApiUrl(path) { const pathParts = window.location.pathname.split('/').filter(part => part !== ''); if (pathParts[0] === 'tunnel' && pathParts[1] && pathParts[2] === 'shared' && pathParts[3]) { // Tunnel context: /tunnel/{tunnelId}/shared/{shareId} // We need to extract just the endpoint part from the path // Example: "shared/abc123/info" -> "info" const parts = path.split('/'); if (parts.length >= 3 && parts[0] === 'shared') { // Return just the endpoint part (after shareId) return parts.slice(2).join('/'); } } // Direct context or fallback: return the path as-is for absolute API calls return '/' + (path.startsWith('/') ? path.substring(1) : path); } // Extract shareId from URL, handling both direct and tunnel access const pathParts = window.location.pathname.split('/'); let shareId; if (pathParts[1] === 'tunnel' && pathParts[3] === 'shared') { // Tunnel context: /tunnel/{tunnelId}/shared/{shareId} shareId = pathParts[4]; } else { // Direct context: /shared/{shareId} shareId = pathParts[2]; } const shareDescriptionEl = document.getElementById('share-description'); const shareExpiresEl = document.getElementById('share-expires'); const chatForm = document.getElementById('shared-chat-form'); const apiKeyInput = document.getElementById('share-api-key'); const queryInput = document.getElementById('chat-query'); const resultsContainer = document.getElementById('chat-results-container'); const resultsEl = document.getElementById('chat-results'); const chatContextContainer = document.getElementById('chat-context-container'); const chatContextCount = document.getElementById('chat-context-count'); const toggleContextBtn = document.getElementById('toggle-context-btn'); const chatContextDetails = document.getElementById('chat-context-details'); const apiUsageContainer = document.getElementById('api-usage'); const snippetInfoCurl = document.getElementById('snippet-info-curl'); const snippetChatCurl = document.getElementById('snippet-chat-curl'); const sharedContent = document.getElementById('shared-content'); // View navigation const viewNav = document.getElementById('view-nav'); const views = { '#chat': document.getElementById('view-chat'), '#files': document.getElementById('view-files'), '#usage': document.getElementById('view-usage'), '#search': document.getElementById('view-search'), }; // File Browser elements const fileTreePanel = document.getElementById('file-tree-panel'); const fileViewerPanel = document.getElementById('file-viewer-panel'); const fileViewerFilename = document.getElementById('file-viewer-filename'); const fileViewerContent = document.getElementById('file-viewer-content'); const fileAskAiBtn = document.getElementById('file-ask-ai-btn'); // Semantic Search elements const searchForm = document.getElementById('search-form'); const searchQueryInput = document.getElementById('search-query'); const searchMaxTokensInput = document.getElementById('max-tokens'); const searchResultsContainer = document.getElementById('search-results-container'); const searchResultsEl = document.getElementById('search-results'); // Global state let fileChatContext = null; // Store API key in sessionStorage to persist across reloads for convenience apiKeyInput.value = sessionStorage.getItem(`gctx_share_key_${shareId}`) || ''; apiKeyInput.addEventListener('input', () => { sessionStorage.setItem(`gctx_share_key_${shareId}`, apiKeyInput.value); }); async function fetchShareInfo() { const apiKey = apiKeyInput.value; if (!apiKey) { shareDescriptionEl.textContent = 'Enter API key to view details.'; shareExpiresEl.textContent = ''; updateApiUsage(null, null); // Hide API usage details sharedContent.style.display = 'none'; return; } try { const response = await fetch(buildApiUrl(`shared/${shareId}/info`), { headers: { 'x-share-key': apiKey } }); if (!response.ok) { const err = await response.json(); sharedContent.style.display = 'none'; throw new Error(err.error || `HTTP ${response.status}`); } const data = await response.json(); shareDescriptionEl.textContent = data.description; shareExpiresEl.textContent = new Date(data.expires_at).toLocaleString(); updateApiUsage(shareId, apiKey); sharedContent.style.display = 'block'; } catch (error) { shareDescriptionEl.textContent = `Error: ${error.message}`; shareExpiresEl.textContent = 'Could not load details.'; updateApiUsage(null, null); // Hide API usage details on error sharedContent.style.display = 'none'; } } apiKeyInput.addEventListener('blur', fetchShareInfo); async function performChat(e) { e.preventDefault(); const apiKey = apiKeyInput.value; const query = queryInput.value; if (!apiKey || !query) { alert('Please provide both an API key and a question.'); return; } resultsContainer.style.display = 'block'; resultsEl.innerHTML = '<p class="loading">Thinking...</p>'; chatForm.querySelector('button').disabled = true; // Reset context display for new query chatContextContainer.style.display = 'none'; chatContextDetails.style.display = 'none'; toggleContextBtn.textContent = 'Show'; try { const body = { query }; if (fileChatContext) { body.options = { filePath: fileChatContext }; fileChatContext = null; // Reset after use queryInput.value = ''; // Clear input after submitting file-context question } const response = await fetch(buildApiUrl(`shared/${shareId}/chat`), { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-share-key': apiKey }, body: JSON.stringify(body) }); if (!response.ok) { const err = await response.json(); throw new Error(err.error || `HTTP ${response.status}`); } const data = await response.json(); if (window.marked) { resultsEl.innerHTML = marked.parse(data.response || 'No response from AI.'); } else { resultsEl.textContent = data.response || 'No response from AI.'; } // Render context chunks if available if (data.context_chunks && Array.isArray(data.context_chunks) && data.context_chunks.length > 0) { chatContextContainer.style.display = 'block'; chatContextCount.textContent = data.context_chunks.length; chatContextDetails.innerHTML = ''; // Clear previous context data.context_chunks.forEach(item => { const card = document.createElement('div'); card.className = 'search-result-card'; const header = document.createElement('div'); header.className = 'result-card-header'; const filePath = item.metadata?.filePath || item.filePath || 'Unknown file'; const score = item.score?.toFixed(3) || 'N/A'; const lineInfo = item.metadata?.start_line ? ` (L${item.metadata.start_line}-${item.metadata.end_line})` : ''; header.innerHTML = ` <span class="file-path">${filePath}${lineInfo}</span> <div class="result-actions"> <span class="score">Score: ${score}</span> <button class="button-secondary view-file-btn" data-path="${filePath}">View File</button> </div> `; const contentEl = document.createElement('pre'); const codeEl = document.createElement('code'); const extension = filePath.split('.').pop(); const langMap = { 'js': 'javascript', 'ts': 'typescript', 'py': 'python', 'java': 'java', 'go': 'go', 'html': 'xml', 'css': 'css', 'json': 'json', 'md': 'markdown' }; const lang = langMap[extension] || 'plaintext'; codeEl.className = `language-${lang}`; codeEl.textContent = item.content || 'No content'; contentEl.appendChild(codeEl); card.appendChild(header); card.appendChild(contentEl); chatContextDetails.appendChild(card); if (window.hljs) { hljs.highlightElement(codeEl); } }); } } catch (error) { resultsEl.innerHTML = `<p class="error">Error: ${error.message}</p>`; } finally { chatForm.querySelector('button').disabled = false; } } chatForm.addEventListener('submit', performChat); // Semantic Search functionality async function performSearch(e) { e.preventDefault(); const apiKey = apiKeyInput.value; const query = searchQueryInput?.value; const maxTokens = parseInt(searchMaxTokensInput?.value || '1000'); if (!apiKey || !query) { alert('Please provide both an API key and a search query.'); return; } if (!searchResultsContainer || !searchResultsEl) { console.error('Search result elements not found'); return; } searchResultsContainer.style.display = 'block'; searchResultsEl.innerHTML = '<p class="loading">Searching...</p>'; const submitBtn = searchForm.querySelector('button[type="submit"]'); if (submitBtn) submitBtn.disabled = true; try { const response = await fetch(buildApiUrl(`shared/${shareId}/search`), { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-share-key': apiKey }, body: JSON.stringify({ query, maxTokens }) }); if (!response.ok) { const err = await response.json(); throw new Error(err.error || `HTTP ${response.status}`); } const data = await response.json(); if (data.results && Array.isArray(data.results) && data.results.length > 0) { searchResultsEl.innerHTML = ''; data.results.forEach(item => { const card = document.createElement('div'); card.className = 'search-result-card'; const header = document.createElement('div'); header.className = 'result-card-header'; const filePath = item.metadata?.filePath || item.filePath || 'Unknown file'; const score = item.score?.toFixed(3) || 'N/A'; const lineInfo = item.metadata?.start_line ? ` (L${item.metadata.start_line}-${item.metadata.end_line})` : ''; header.innerHTML = ` <span class="file-path">${filePath}${lineInfo}</span> <div class="result-actions"> <span class="score">Score: ${score}</span> <button class="button-secondary view-file-btn" data-path="${filePath}">View File</button> </div> `; const contentEl = document.createElement('pre'); const codeEl = document.createElement('code'); const extension = filePath.split('.').pop(); const langMap = { 'js': 'javascript', 'ts': 'typescript', 'py': 'python', 'java': 'java', 'go': 'go', 'html': 'xml', 'css': 'css', 'json': 'json', 'md': 'markdown' }; const lang = langMap[extension] || 'plaintext'; codeEl.className = `language-${lang}`; codeEl.textContent = item.content || 'No content'; contentEl.appendChild(codeEl); card.appendChild(header); card.appendChild(contentEl); searchResultsEl.appendChild(card); if (window.hljs) { hljs.highlightElement(codeEl); } }); } else { searchResultsEl.innerHTML = '<p>No results found.</p>'; } } catch (error) { searchResultsEl.innerHTML = `<p class="error">Error: ${error.message}</p>`; } finally { const submitBtn = searchForm.querySelector('button[type="submit"]'); if (submitBtn) submitBtn.disabled = false; } } if (searchForm) { searchForm.addEventListener('submit', performSearch); } toggleContextBtn.addEventListener('click', () => { const isHidden = chatContextDetails.style.display === 'none'; chatContextDetails.style.display = isHidden ? 'block' : 'none'; toggleContextBtn.textContent = isHidden ? 'Hide' : 'Show'; }); function updateApiUsage(shareId, apiKey) { if (!apiKey || !shareId) { apiUsageContainer.style.display = 'none'; return; } const baseUrl = window.location.origin; const infoUrl = `${baseUrl}/shared/${shareId}/info`; const chatUrl = `${baseUrl}/shared/${shareId}/chat`; const searchUrl = `${baseUrl}/shared/${shareId}/search`; const infoSnippet = `curl "${infoUrl}" \\\n -H "x-share-key: ${apiKey}"`; snippetInfoCurl.textContent = infoSnippet; const chatSnippet = `curl -X POST "${chatUrl}" \\\n -H "Content-Type: application/json" \\\n -H "x-share-key: ${apiKey}" \\\n -d '{"query": "What is the main authentication pattern?"}'`; snippetChatCurl.textContent = chatSnippet; const searchSnippet = `curl -X POST "${searchUrl}" \\\n -H "Content-Type: application/json" \\\n -H "x-share-key: ${apiKey}" \\\n -d '{"query": "function definition", "maxTokens": 1000}'`; const snippetSearchCurl = document.getElementById('snippet-search-curl'); if (snippetSearchCurl) { snippetSearchCurl.textContent = searchSnippet; } apiUsageContainer.style.display = 'block'; } // --- View Navigation & File Browser Logic (adapted from app.js) --- function switchView(hash) { const targetHash = hash || '#chat'; const [viewId, ...params] = targetHash.split('::'); Object.entries(views).forEach(([viewHash, viewElement]) => { if (!viewElement) return; viewElement.classList.toggle('hidden', viewHash !== viewId); }); viewNav.querySelectorAll('a').forEach(a => { a.classList.toggle('active', a.getAttribute('href') === viewId); }); if (viewId === '#files') { if (apiKeyInput.value && fileTreePanel && !fileTreePanel.dataset.initialized) { fetchFileTree(); } if (params.length > 0) { const filePath = decodeURIComponent(params.join('::')); fetchAndShowFile(filePath); } } } async function fetchWithAuth(url) { const apiKey = apiKeyInput.value; if (!apiKey) throw new Error('API Key is missing.'); return fetch(url, { headers: { 'x-share-key': apiKey } }); } async function fetchFileTree() { if (!fileTreePanel) return; fileTreePanel.dataset.initialized = 'true'; try { const response = await fetchWithAuth(buildApiUrl(`shared/${shareId}/files/tree`)); if (!response.ok) throw new Error(`HTTP ${response.status}`); const tree = await response.json(); fileTreePanel.innerHTML = ''; const treeRoot = document.createElement('ul'); treeRoot.className = 'file-tree'; renderFileTree(tree, treeRoot, 0); fileTreePanel.appendChild(treeRoot); } catch (error) { fileTreePanel.innerHTML = `<p class="error">Could not load file tree: ${error.message}</p>`; } } function renderFileTree(nodes, container, depth) { nodes.sort((a,b) => (a.type === 'directory' ? -1 : 1) - (b.type === 'directory' ? -1 : 1) || a.name.localeCompare(b.name)); nodes.forEach(node => { const li = document.createElement('li'); li.className = `file-tree-node type-${node.type}`; const link = document.createElement('a'); link.href = node.type === 'directory' ? '#' : `#files::${encodeURIComponent(node.path)}`; link.dataset.path = node.path; link.innerHTML = `<span class="icon"></span> ${node.name}`; li.style.paddingLeft = `${depth * 15}px`; li.appendChild(link); if (node.type === 'directory' && node.children?.length > 0) { const childrenUl = document.createElement('ul'); childrenUl.className = 'file-tree-subtree'; renderFileTree(node.children, childrenUl, depth + 1); li.appendChild(childrenUl); } container.appendChild(li); }); } async function fetchAndShowFile(filePath) { if (!fileViewerPanel) return; fileViewerPanel.style.display = 'flex'; fileViewerFilename.textContent = 'Loading...'; fileViewerContent.innerHTML = '<div class="loading">Loading...</div>'; try { const response = await fetchWithAuth(buildApiUrl(`shared/${shareId}/files/content?path=${encodeURIComponent(filePath)}`)); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); fileViewerFilename.textContent = filePath; if (window.marked) { fileViewerContent.innerHTML = window.marked.parse(data.content); } else { fileViewerContent.innerHTML = `<pre><code>${data.content.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</code></pre>`; } fileAskAiBtn.dataset.filePath = filePath; fileViewerContent.querySelectorAll('pre code').forEach(block => { if (window.hljs) { hljs.highlightElement(block); } }); } catch (error) { fileViewerFilename.textContent = `Error: ${filePath}`; fileViewerContent.innerHTML = `<p class="error">Could not load file: ${error.message}</p>`; } } // Setup listeners viewNav.addEventListener('click', e => { if (e.target.tagName === 'A') { e.preventDefault(); const hash = e.target.getAttribute('href'); if (window.location.hash !== hash) { window.location.hash = hash; } else { switchView(hash); } } }); document.body.addEventListener('click', e => { const viewFileBtn = e.target.closest('.view-file-btn'); if (viewFileBtn) { e.preventDefault(); window.location.hash = `#files::${encodeURIComponent(viewFileBtn.dataset.path)}`; return; } const fileTreeNode = e.target.closest('.file-tree-node a'); if (fileTreeNode) { e.preventDefault(); const node = fileTreeNode.parentElement; if (node.classList.contains('type-file')) { window.location.hash = fileTreeNode.getAttribute('href'); } else if (node.classList.contains('type-directory')) { node.classList.toggle('open'); } } }); fileAskAiBtn.addEventListener('click', () => { const filePath = fileAskAiBtn.dataset.filePath; if (filePath) { fileChatContext = filePath; window.location.hash = '#chat'; setTimeout(() => { queryInput.value = `Regarding the file ${filePath}, `; queryInput.focus(); }, 100); } }); window.addEventListener('hashchange', () => switchView(window.location.hash)); // Initial fetch if key is pre-filled if (apiKeyInput.value) { fetchShareInfo(); } switchView(window.location.hash); });