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:
@@ -10,7 +10,7 @@ import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './servi
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
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 () => {
|
onMounted(async () => {
|
||||||
// Initialize WebMCP connection
|
// Initialize WebMCP connection
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ onMounted(() => {
|
|||||||
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
|
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</RouterLink>
|
</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>
|
||||||
|
|
||||||
<div class="toolbar-divider"></div>
|
<div class="toolbar-divider"></div>
|
||||||
|
|||||||
951
frontend/src/pages/SourceCodePage.vue
Normal file
951
frontend/src/pages/SourceCodePage.vue
Normal 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>
|
||||||
@@ -38,6 +38,11 @@ const router = createRouter({
|
|||||||
path: '/database',
|
path: '/database',
|
||||||
name: 'database',
|
name: 'database',
|
||||||
component: () => import('../pages/DatabasePage.vue')
|
component: () => import('../pages/DatabasePage.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/source',
|
||||||
|
name: 'source',
|
||||||
|
component: () => import('../pages/SourceCodePage.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,8 +29,13 @@ import {
|
|||||||
unregisterDatabaseTools,
|
unregisterDatabaseTools,
|
||||||
DATABASE_TOOLS
|
DATABASE_TOOLS
|
||||||
} from './tools/databaseTools'
|
} 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 {
|
interface PageToolSet {
|
||||||
register: () => void
|
register: () => void
|
||||||
@@ -95,6 +100,11 @@ const pageTools: Record<PageName, PageToolSet> = {
|
|||||||
register: registerDatabaseTools,
|
register: registerDatabaseTools,
|
||||||
unregister: unregisterDatabaseTools,
|
unregister: unregisterDatabaseTools,
|
||||||
toolNames: DATABASE_TOOLS
|
toolNames: DATABASE_TOOLS
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
register: registerSourceCodeTools,
|
||||||
|
unregister: unregisterSourceCodeTools,
|
||||||
|
toolNames: SOURCE_CODE_TOOLS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
290
frontend/src/services/tools/sourceCodeTools.ts
Normal file
290
frontend/src/services/tools/sourceCodeTools.ts
Normal 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
|
||||||
|
}
|
||||||
136
server/index.ts
136
server/index.ts
@@ -861,6 +861,142 @@ Bun.serve({
|
|||||||
}, { headers: corsHeaders })
|
}, { 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
|
// API de Database Explorer
|
||||||
// =====================
|
// =====================
|
||||||
|
|||||||
Reference in New Issue
Block a user