- Create traefik/agent-ui.yml with full routing config for domain z590.nucleoriofrio.com - Add frontend/src/config/endpoints.ts for automatic HTTP/HTTPS detection - Update all hardcoded localhost URLs to use relative paths - WebSocket connections auto-detect wss:// vs ws:// based on page protocol - Configure path-based WebSocket routing (/ws/terminal, /ws/mcp, /ws/status, /ws/whisper) - Add commented IP whitelist middleware for future security
952 lines
23 KiB
Vue
952 lines
23 KiB
Vue
<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('/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('/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('/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>
|