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

@@ -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
}