feat: Add source code viewer with Gitea integration

- Add /source page with file explorer and code viewer
- Connect to Gitea API for repository browsing
- Implement MCP tools: get_repo_info, list_repo_files, read_repo_file, search_repo_code
- Default to nucleo000/agent-ui repository
- Support branch switching and file search
This commit is contained in:
2026-02-13 06:50:58 -06:00
parent 97ef49aea4
commit 2a2100bbb2
7 changed files with 1401 additions and 2 deletions

View File

@@ -10,7 +10,7 @@ import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './servi
const route = useRoute()
const router = useRouter()
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database'
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source'
onMounted(async () => {
// Initialize WebMCP connection

View File

@@ -103,6 +103,13 @@ onMounted(() => {
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
</svg>
</RouterLink>
<RouterLink to="/source" class="toolbar-btn" :class="{ active: route.path === '/source' }" title="Source Code">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
</svg>
</RouterLink>
</div>
<div class="toolbar-divider"></div>

View File

@@ -0,0 +1,951 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
interface FileNode {
name: string
path: string
type: 'file' | 'dir'
children?: FileNode[]
expanded?: boolean
}
interface RepoInfo {
name: string
description: string
default_branch: string
stars_count: number
forks_count: number
owner: { login: string }
}
// Connection settings
const giteaUrl = ref('https://gitea.nucleoriofrio.com')
const username = ref('')
const password = ref('')
const owner = ref('nucleo000')
const repo = ref('agent-ui')
const connected = ref(false)
const connecting = ref(false)
const connectionError = ref<string | null>(null)
// Repository data
const repoInfo = ref<RepoInfo | null>(null)
const fileTree = ref<FileNode[]>([])
const currentBranch = ref('main')
const branches = ref<string[]>([])
// File viewer
const selectedFile = ref<string | null>(null)
const fileContent = ref<string>('')
const fileLoading = ref(false)
const fileName = ref('')
// UI state
const showSettings = ref(true)
const searchQuery = ref('')
// Load saved settings from localStorage
onMounted(() => {
const saved = localStorage.getItem('gitea_settings')
if (saved) {
try {
const settings = JSON.parse(saved)
giteaUrl.value = settings.giteaUrl || giteaUrl.value
username.value = settings.username || ''
owner.value = settings.owner || ''
repo.value = settings.repo || ''
// Don't load password from localStorage for security
} catch (e) {
console.error('Error loading saved settings')
}
}
})
// Save settings (except password)
function saveSettings() {
localStorage.setItem('gitea_settings', JSON.stringify({
giteaUrl: giteaUrl.value,
username: username.value,
owner: owner.value,
repo: repo.value
}))
}
async function connect() {
if (!username.value || !password.value || !owner.value || !repo.value) {
connectionError.value = 'All fields are required'
return
}
connecting.value = true
connectionError.value = null
try {
// Test connection and get repo info
const res = await fetch('http://localhost:4101/api/gitea/repo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
giteaUrl: giteaUrl.value,
username: username.value,
password: password.value,
owner: owner.value,
repo: repo.value
})
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || 'Connection failed')
}
const data = await res.json()
repoInfo.value = data.repo
branches.value = data.branches || ['main']
currentBranch.value = data.repo.default_branch || 'main'
// Save settings and load file tree
saveSettings()
connected.value = true
showSettings.value = false
await loadFileTree()
} catch (e: any) {
connectionError.value = e.message
} finally {
connecting.value = false
}
}
async function loadFileTree(path: string = '') {
try {
const res = await fetch('http://localhost:4101/api/gitea/tree', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
giteaUrl: giteaUrl.value,
username: username.value,
password: password.value,
owner: owner.value,
repo: repo.value,
branch: currentBranch.value,
path
})
})
if (!res.ok) throw new Error('Failed to load file tree')
const data = await res.json()
if (path === '') {
fileTree.value = data.tree
} else {
// Update nested path
updateTreeNode(fileTree.value, path, data.tree)
}
} catch (e: any) {
console.error('Error loading tree:', e)
}
}
function updateTreeNode(nodes: FileNode[], path: string, children: FileNode[]) {
for (const node of nodes) {
if (node.path === path) {
node.children = children
node.expanded = true
return true
}
if (node.children && updateTreeNode(node.children, path, children)) {
return true
}
}
return false
}
async function toggleFolder(node: FileNode) {
if (node.type !== 'dir') return
if (node.expanded) {
node.expanded = false
} else {
if (!node.children || node.children.length === 0) {
await loadFileTree(node.path)
}
node.expanded = true
}
}
async function selectFile(node: FileNode) {
if (node.type !== 'file') return
selectedFile.value = node.path
fileName.value = node.name
fileLoading.value = true
fileContent.value = ''
try {
const res = await fetch('http://localhost:4101/api/gitea/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
giteaUrl: giteaUrl.value,
username: username.value,
password: password.value,
owner: owner.value,
repo: repo.value,
branch: currentBranch.value,
path: node.path
})
})
if (!res.ok) throw new Error('Failed to load file')
const data = await res.json()
fileContent.value = data.content
} catch (e: any) {
fileContent.value = `Error loading file: ${e.message}`
} finally {
fileLoading.value = false
}
}
function disconnect() {
connected.value = false
repoInfo.value = null
fileTree.value = []
selectedFile.value = null
fileContent.value = ''
password.value = ''
showSettings.value = true
}
function getFileExtension(filename: string): string {
return filename.split('.').pop()?.toLowerCase() || ''
}
function getLanguageClass(filename: string): string {
const ext = getFileExtension(filename)
const langMap: Record<string, string> = {
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
vue: 'vue',
html: 'html',
css: 'css',
scss: 'scss',
json: 'json',
md: 'markdown',
py: 'python',
go: 'go',
rs: 'rust',
sql: 'sql',
sh: 'bash',
yml: 'yaml',
yaml: 'yaml'
}
return langMap[ext] || 'plaintext'
}
function getFileIcon(node: FileNode): string {
if (node.type === 'dir') return ''
const ext = getFileExtension(node.name)
const icons: Record<string, string> = {
ts: 'TS',
tsx: 'TX',
js: 'JS',
vue: 'V',
json: '{}',
md: 'M',
css: '#',
html: '<>',
py: 'Py',
go: 'Go'
}
return icons[ext] || ''
}
const filteredTree = computed(() => {
if (!searchQuery.value) return fileTree.value
function filterNodes(nodes: FileNode[]): FileNode[] {
return nodes.filter(node => {
if (node.name.toLowerCase().includes(searchQuery.value.toLowerCase())) {
return true
}
if (node.children) {
const filtered = filterNodes(node.children)
if (filtered.length > 0) {
node.children = filtered
node.expanded = true
return true
}
}
return false
})
}
return filterNodes(JSON.parse(JSON.stringify(fileTree.value)))
})
async function changeBranch() {
fileTree.value = []
selectedFile.value = null
fileContent.value = ''
await loadFileTree()
}
const lineNumbers = computed(() => {
if (!fileContent.value) return []
return fileContent.value.split('\n').map((_, i) => i + 1)
})
</script>
<template>
<div class="source-page">
<!-- Sidebar -->
<aside class="sidebar" :class="{ collapsed: !showSettings && connected }">
<!-- Settings Panel -->
<div v-if="showSettings || !connected" class="settings-panel">
<div class="settings-header">
<h2>Gitea Connection</h2>
</div>
<div class="settings-form">
<div class="form-group">
<label>Gitea URL</label>
<input
v-model="giteaUrl"
type="text"
placeholder="https://gitea.example.com"
/>
</div>
<div class="form-group">
<label>Username</label>
<input
v-model="username"
type="text"
placeholder="your-username"
autocomplete="username"
/>
</div>
<div class="form-group">
<label>Password / Token</label>
<input
v-model="password"
type="password"
placeholder="password or access token"
autocomplete="current-password"
/>
</div>
<div class="form-row">
<div class="form-group">
<label>Owner</label>
<input
v-model="owner"
type="text"
placeholder="owner"
/>
</div>
<div class="form-group">
<label>Repository</label>
<input
v-model="repo"
type="text"
placeholder="repo-name"
/>
</div>
</div>
<div v-if="connectionError" class="error-message">
{{ connectionError }}
</div>
<button
class="btn-primary"
@click="connect"
:disabled="connecting"
>
{{ connecting ? 'Connecting...' : 'Connect' }}
</button>
</div>
</div>
<!-- File Tree -->
<div v-else class="file-explorer">
<div class="explorer-header">
<div class="repo-info">
<span class="repo-name">{{ owner }}/{{ repo }}</span>
<select v-model="currentBranch" @change="changeBranch" class="branch-select">
<option v-for="branch in branches" :key="branch" :value="branch">
{{ branch }}
</option>
</select>
</div>
<button class="btn-icon" @click="showSettings = true" title="Settings">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
</button>
</div>
<div class="search-box">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="searchQuery"
type="text"
placeholder="Search files..."
/>
</div>
<div class="tree-container">
<template v-for="node in filteredTree" :key="node.path">
<div
class="tree-node"
:class="{
folder: node.type === 'dir',
file: node.type === 'file',
selected: selectedFile === node.path
}"
@click="node.type === 'dir' ? toggleFolder(node) : selectFile(node)"
>
<span v-if="node.type === 'dir'" class="node-icon folder-icon">
<svg v-if="node.expanded" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</span>
<span v-else class="node-icon file-icon">{{ getFileIcon(node) }}</span>
<span class="node-name">{{ node.name }}</span>
</div>
<!-- Nested children -->
<div v-if="node.type === 'dir' && node.expanded && node.children" class="tree-children">
<template v-for="child in node.children" :key="child.path">
<div
class="tree-node"
:class="{
folder: child.type === 'dir',
file: child.type === 'file',
selected: selectedFile === child.path
}"
@click="child.type === 'dir' ? toggleFolder(child) : selectFile(child)"
>
<span v-if="child.type === 'dir'" class="node-icon folder-icon">
<svg v-if="child.expanded" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1 2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</span>
<span v-else class="node-icon file-icon">{{ getFileIcon(child) }}</span>
<span class="node-name">{{ child.name }}</span>
</div>
<!-- Level 2 children -->
<div v-if="child.type === 'dir' && child.expanded && child.children" class="tree-children">
<div
v-for="grandchild in child.children"
:key="grandchild.path"
class="tree-node"
:class="{
folder: grandchild.type === 'dir',
file: grandchild.type === 'file',
selected: selectedFile === grandchild.path
}"
@click="grandchild.type === 'dir' ? toggleFolder(grandchild) : selectFile(grandchild)"
>
<span v-if="grandchild.type === 'dir'" class="node-icon folder-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</span>
<span v-else class="node-icon file-icon">{{ getFileIcon(grandchild) }}</span>
<span class="node-name">{{ grandchild.name }}</span>
</div>
</div>
</template>
</div>
</template>
</div>
<div class="explorer-footer">
<button class="btn-disconnect" @click="disconnect">
Disconnect
</button>
</div>
</div>
</aside>
<!-- Code Viewer -->
<main class="code-viewer">
<div v-if="!connected" class="placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
</svg>
<h3>Source Code Viewer</h3>
<p>Connect to Gitea to browse your repository</p>
</div>
<div v-else-if="!selectedFile" class="placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<p>Select a file to view its contents</p>
</div>
<div v-else class="file-view">
<div class="file-header">
<div class="file-path">
<span class="path-segment" v-for="(segment, idx) in selectedFile.split('/')" :key="idx">
{{ segment }}
<span v-if="idx < selectedFile.split('/').length - 1" class="separator">/</span>
</span>
</div>
<div class="file-actions">
<span class="language-badge">{{ getLanguageClass(fileName) }}</span>
</div>
</div>
<div class="code-container">
<div v-if="fileLoading" class="loading">Loading file...</div>
<div v-else class="code-content">
<div class="line-numbers">
<span v-for="num in lineNumbers" :key="num">{{ num }}</span>
</div>
<pre class="code-text"><code>{{ fileContent }}</code></pre>
</div>
</div>
</div>
</main>
</div>
</template>
<style scoped>
.source-page {
display: flex;
flex: 1;
overflow: hidden;
background: var(--bg-primary);
}
/* Sidebar */
.sidebar {
width: 300px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: width 0.2s ease;
}
.sidebar.collapsed {
width: 280px;
}
/* Settings Panel */
.settings-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.settings-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-color);
}
.settings-header h2 {
margin: 0;
font-size: 1rem;
color: var(--text-primary);
}
.settings-form {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-group label {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input {
padding: 0.625rem 0.875rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.875rem;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.form-row {
display: flex;
gap: 0.75rem;
}
.form-row .form-group {
flex: 1;
}
.error-message {
padding: 0.625rem;
background: var(--error-bg);
color: var(--error);
border-radius: 6px;
font-size: 0.8rem;
}
.btn-primary {
padding: 0.625rem 1rem;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* File Explorer */
.file-explorer {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.explorer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.repo-info {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.repo-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary);
}
.branch-select {
padding: 0.25rem 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
font-size: 0.75rem;
cursor: pointer;
}
.btn-icon {
padding: 0.375rem;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--accent);
}
.search-box {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.search-box svg {
color: var(--text-muted);
flex-shrink: 0;
}
.search-box input {
flex: 1;
padding: 0.375rem;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 0.8rem;
}
.search-box input:focus {
outline: none;
}
.tree-container {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.tree-node {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
border-radius: 4px;
cursor: pointer;
transition: background 0.1s;
}
.tree-node:hover {
background: var(--bg-hover);
}
.tree-node.selected {
background: var(--accent-muted);
color: var(--accent);
}
.tree-children {
padding-left: 1rem;
}
.node-icon {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.folder-icon {
color: var(--accent);
}
.file-icon {
font-size: 0.65rem;
font-weight: 600;
color: var(--text-muted);
background: var(--bg-tertiary);
border-radius: 2px;
}
.node-name {
font-size: 0.8rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-node.selected .node-name {
color: var(--accent);
}
.explorer-footer {
padding: 0.75rem 1rem;
border-top: 1px solid var(--border-color);
}
.btn-disconnect {
width: 100%;
padding: 0.5rem;
background: var(--bg-hover);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
}
.btn-disconnect:hover {
background: var(--error-bg);
color: var(--error);
border-color: var(--error);
}
/* Code Viewer */
.code-viewer {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
gap: 1rem;
}
.placeholder h3 {
margin: 0;
color: var(--text-primary);
}
.placeholder p {
margin: 0;
font-size: 0.9rem;
}
.file-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.file-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.file-path {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.125rem;
}
.path-segment {
font-size: 0.85rem;
color: var(--text-secondary);
}
.path-segment:last-child {
color: var(--text-primary);
font-weight: 500;
}
.separator {
color: var(--text-muted);
margin: 0 0.125rem;
}
.language-badge {
padding: 0.25rem 0.5rem;
background: var(--accent-muted);
color: var(--accent);
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.code-container {
flex: 1;
overflow: auto;
background: var(--bg-primary);
}
.loading {
padding: 2rem;
text-align: center;
color: var(--text-muted);
}
.code-content {
display: flex;
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.6;
}
.line-numbers {
display: flex;
flex-direction: column;
padding: 1rem 0.75rem;
background: var(--bg-secondary);
color: var(--text-muted);
text-align: right;
user-select: none;
border-right: 1px solid var(--border-color);
position: sticky;
left: 0;
}
.line-numbers span {
font-size: 0.75rem;
}
.code-text {
margin: 0;
padding: 1rem;
flex: 1;
overflow-x: auto;
color: var(--text-primary);
white-space: pre;
tab-size: 2;
}
.code-text code {
font-family: inherit;
}
/* Responsive */
@media (max-width: 768px) {
.source-page {
flex-direction: column;
}
.sidebar {
width: 100%;
max-height: 50vh;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
}
</style>

View File

@@ -38,6 +38,11 @@ const router = createRouter({
path: '/database',
name: 'database',
component: () => import('../pages/DatabasePage.vue')
},
{
path: '/source',
name: 'source',
component: () => import('../pages/SourceCodePage.vue')
}
]
})

View File

@@ -29,8 +29,13 @@ import {
unregisterDatabaseTools,
DATABASE_TOOLS
} from './tools/databaseTools'
import {
registerSourceCodeTools,
unregisterSourceCodeTools,
SOURCE_CODE_TOOLS
} from './tools/sourceCodeTools'
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database'
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source'
interface PageToolSet {
register: () => void
@@ -95,6 +100,11 @@ const pageTools: Record<PageName, PageToolSet> = {
register: registerDatabaseTools,
unregister: unregisterDatabaseTools,
toolNames: DATABASE_TOOLS
},
source: {
register: registerSourceCodeTools,
unregister: unregisterSourceCodeTools,
toolNames: SOURCE_CODE_TOOLS
}
}

View File

@@ -0,0 +1,290 @@
import { registerTool, unregisterTools } from '../webmcp'
export const SOURCE_CODE_TOOLS = [
'get_repo_info',
'list_repo_files',
'read_repo_file',
'search_repo_code'
]
const API_BASE = 'http://localhost:4101'
// Store credentials in memory (not persisted)
let giteaCredentials: {
giteaUrl: string
username: string
password: string
owner: string
repo: string
branch: string
} | null = null
export function setGiteaCredentials(creds: typeof giteaCredentials) {
giteaCredentials = creds
}
export function registerSourceCodeTools() {
// get_repo_info
registerTool(
'get_repo_info',
'Obtiene informacion del repositorio conectado en Gitea',
{
type: 'object',
properties: {}
},
async () => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en la pagina de Source Code.'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/repo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(giteaCredentials)
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
const repo = data.repo
return `Repositorio: ${repo.owner.login}/${repo.name}\n` +
`Descripcion: ${repo.description || 'Sin descripcion'}\n` +
`Rama default: ${repo.default_branch}\n` +
`Stars: ${repo.stars_count}\n` +
`Forks: ${repo.forks_count}\n` +
`Ramas disponibles: ${data.branches.join(', ')}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// list_repo_files
registerTool(
'list_repo_files',
'Lista archivos y carpetas en una ruta del repositorio',
{
type: 'object',
properties: {
path: {
type: 'string',
description: 'Ruta dentro del repositorio (vacio para raiz)'
}
}
},
async (args: { path?: string }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en la pagina de Source Code.'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/tree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...giteaCredentials,
path: args.path || ''
})
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
const path = args.path || '/'
if (data.tree.length === 0) {
return `No hay archivos en ${path}`
}
const folders = data.tree.filter((f: any) => f.type === 'dir')
const files = data.tree.filter((f: any) => f.type === 'file')
let result = `Contenido de ${path}:\n\n`
if (folders.length > 0) {
result += `Carpetas (${folders.length}):\n`
result += folders.map((f: any) => ` [DIR] ${f.name}/`).join('\n')
result += '\n\n'
}
if (files.length > 0) {
result += `Archivos (${files.length}):\n`
result += files.map((f: any) => ` ${f.name}`).join('\n')
}
return result
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// read_repo_file
registerTool(
'read_repo_file',
'Lee el contenido de un archivo del repositorio',
{
type: 'object',
properties: {
path: {
type: 'string',
description: 'Ruta del archivo dentro del repositorio'
},
lines: {
type: 'number',
description: 'Numero maximo de lineas a retornar (default: 100)'
}
},
required: ['path']
},
async (args: { path: string; lines?: number }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en la pagina de Source Code.'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...giteaCredentials,
path: args.path
})
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
let content = data.content
// Limit lines if specified
const maxLines = args.lines || 100
const lines = content.split('\n')
if (lines.length > maxLines) {
content = lines.slice(0, maxLines).join('\n')
content += `\n\n... (${lines.length - maxLines} lineas mas)`
}
return `Archivo: ${args.path}\nTamano: ${data.size} bytes\n\n${content}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// search_repo_code
registerTool(
'search_repo_code',
'Busca codigo en el repositorio (busqueda simple en archivos)',
{
type: 'object',
properties: {
query: {
type: 'string',
description: 'Texto a buscar en los archivos'
},
path: {
type: 'string',
description: 'Ruta donde buscar (default: raiz)'
},
extension: {
type: 'string',
description: 'Extension de archivos a buscar (ej: ts, vue, js)'
}
},
required: ['query']
},
async (args: { query: string; path?: string; extension?: string }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en la pagina de Source Code.'
}
try {
// First get the file tree
const treeRes = await fetch(`${API_BASE}/api/gitea/tree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...giteaCredentials,
path: args.path || ''
})
})
if (!treeRes.ok) {
return 'Error al obtener lista de archivos'
}
const treeData = await treeRes.json()
const files = treeData.tree.filter((f: any) => {
if (f.type !== 'file') return false
if (args.extension) {
return f.name.endsWith(`.${args.extension}`)
}
return true
})
const results: string[] = []
const maxFiles = 10
let filesSearched = 0
for (const file of files.slice(0, maxFiles)) {
try {
const fileRes = await fetch(`${API_BASE}/api/gitea/file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...giteaCredentials,
path: file.path
})
})
if (fileRes.ok) {
const fileData = await fileRes.json()
const lines = fileData.content.split('\n')
const matches: string[] = []
lines.forEach((line: string, idx: number) => {
if (line.toLowerCase().includes(args.query.toLowerCase())) {
matches.push(` L${idx + 1}: ${line.trim().substring(0, 80)}`)
}
})
if (matches.length > 0) {
results.push(`${file.path}:\n${matches.slice(0, 5).join('\n')}`)
}
}
filesSearched++
} catch (e) {
// Skip file errors
}
}
if (results.length === 0) {
return `No se encontro "${args.query}" en los primeros ${filesSearched} archivos`
}
return `Busqueda: "${args.query}"\n` +
`Archivos buscados: ${filesSearched}\n` +
`Coincidencias:\n\n${results.join('\n\n')}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
}
export function unregisterSourceCodeTools() {
unregisterTools(SOURCE_CODE_TOOLS)
giteaCredentials = null
}

View File

@@ -861,6 +861,142 @@ Bun.serve({
}, { headers: corsHeaders })
}
// =====================
// API de Gitea (Source Code Viewer)
// =====================
// POST /api/gitea/repo - Connect and get repo info
if (url.pathname === '/api/gitea/repo' && req.method === 'POST') {
const body = await req.json()
const { giteaUrl, username, password, owner, repo } = body
if (!giteaUrl || !username || !password || !owner || !repo) {
return Response.json({ error: 'Missing required fields' }, { status: 400, headers: corsHeaders })
}
try {
const auth = Buffer.from(`${username}:${password}`).toString('base64')
// Get repo info
const repoRes = await fetch(`${giteaUrl}/api/v1/repos/${owner}/${repo}`, {
headers: { 'Authorization': `Basic ${auth}` }
})
if (!repoRes.ok) {
if (repoRes.status === 401) {
return Response.json({ error: 'Invalid credentials' }, { status: 401, headers: corsHeaders })
}
if (repoRes.status === 404) {
return Response.json({ error: 'Repository not found' }, { status: 404, headers: corsHeaders })
}
throw new Error('Failed to connect to Gitea')
}
const repoData = await repoRes.json()
// Get branches
const branchesRes = await fetch(`${giteaUrl}/api/v1/repos/${owner}/${repo}/branches`, {
headers: { 'Authorization': `Basic ${auth}` }
})
let branches = ['main']
if (branchesRes.ok) {
const branchesData = await branchesRes.json()
branches = branchesData.map((b: any) => b.name)
}
return Response.json({
repo: {
name: repoData.name,
description: repoData.description,
default_branch: repoData.default_branch,
stars_count: repoData.stars_count,
forks_count: repoData.forks_count,
owner: { login: repoData.owner?.login || owner }
},
branches
}, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 500, headers: corsHeaders })
}
}
// POST /api/gitea/tree - Get file tree
if (url.pathname === '/api/gitea/tree' && req.method === 'POST') {
const body = await req.json()
const { giteaUrl, username, password, owner, repo, branch, path } = body
try {
const auth = Buffer.from(`${username}:${password}`).toString('base64')
const apiPath = path ? `${giteaUrl}/api/v1/repos/${owner}/${repo}/contents/${path}?ref=${branch}`
: `${giteaUrl}/api/v1/repos/${owner}/${repo}/contents?ref=${branch}`
const res = await fetch(apiPath, {
headers: { 'Authorization': `Basic ${auth}` }
})
if (!res.ok) {
throw new Error('Failed to load tree')
}
const data = await res.json()
const items = Array.isArray(data) ? data : [data]
const tree = items
.map((item: any) => ({
name: item.name,
path: item.path,
type: item.type === 'dir' ? 'dir' : 'file',
children: item.type === 'dir' ? [] : undefined
}))
.sort((a: any, b: any) => {
// Folders first, then files
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
return Response.json({ tree }, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 500, headers: corsHeaders })
}
}
// POST /api/gitea/file - Get file content
if (url.pathname === '/api/gitea/file' && req.method === 'POST') {
const body = await req.json()
const { giteaUrl, username, password, owner, repo, branch, path } = body
try {
const auth = Buffer.from(`${username}:${password}`).toString('base64')
const res = await fetch(
`${giteaUrl}/api/v1/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
{ headers: { 'Authorization': `Basic ${auth}` } }
)
if (!res.ok) {
throw new Error('Failed to load file')
}
const data = await res.json()
// Decode base64 content
let content = ''
if (data.content) {
content = Buffer.from(data.content, 'base64').toString('utf-8')
}
return Response.json({
content,
encoding: data.encoding,
size: data.size,
sha: data.sha
}, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 500, headers: corsHeaders })
}
}
// =====================
// API de Database Explorer
// =====================