Files
ams-frontend/src/views/GitLabView.vue
2026-02-19 14:03:01 +00:00

1187 lines
37 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick, shallowRef } from 'vue'
import { api } from '../api'
import Dropdown from 'primevue/dropdown'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tree from 'primevue/tree'
import Dialog from 'primevue/dialog'
import Textarea from 'primevue/textarea'
import InputText from 'primevue/inputtext'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import hljs from 'highlight.js'
import loader from '@monaco-editor/loader'
const toast = useToast()
// Monaco Editor instance
const monacoEditor = shallowRef<any>(null)
const monacoInstance = shallowRef<any>(null)
const editorContainer = ref<HTMLElement | null>(null)
// Monaco themes mapping (highlight.js theme -> Monaco theme)
const monacoThemeMap: Record<string, string> = {
'github-dark': 'vs-dark',
'github-dark-dimmed': 'vs-dark',
'monokai': 'monokai',
'dracula': 'dracula',
'atom-one-dark': 'vs-dark',
'vs2015': 'vs-dark',
'nord': 'nord',
'tokyo-night-dark': 'vs-dark',
'agate': 'vs-dark',
'androidstudio': 'vs-dark',
'hybrid': 'vs-dark',
'obsidian': 'vs-dark',
'stackoverflow-dark': 'vs-dark',
'night-owl': 'night-owl',
'a11y-dark': 'vs-dark',
}
// Code themes list
const codeThemes = [
{ value: 'github-dark', label: 'GitHub Dark' },
{ value: 'github-dark-dimmed', label: 'GitHub Dimmed' },
{ value: 'monokai', label: 'Monokai' },
{ value: 'dracula', label: 'Dracula' },
{ value: 'atom-one-dark', label: 'Atom One Dark' },
{ value: 'vs2015', label: 'VS 2015' },
{ value: 'nord', label: 'Nord' },
{ value: 'tokyo-night-dark', label: 'Tokyo Night' },
{ value: 'agate', label: 'Agate' },
{ value: 'androidstudio', label: 'Android Studio' },
{ value: 'hybrid', label: 'Hybrid' },
{ value: 'obsidian', label: 'Obsidian' },
{ value: 'stackoverflow-dark', label: 'Stack Overflow' },
{ value: 'night-owl', label: 'Night Owl' },
{ value: 'a11y-dark', label: 'A11y Dark' },
]
// Dynamic theme loading
const currentTheme = ref(localStorage.getItem('ams_code_theme') || 'github-dark')
let themeLink: HTMLLinkElement | null = null
function loadTheme(themeName: string) {
if (themeLink) {
themeLink.remove()
}
themeLink = document.createElement('link')
themeLink.rel = 'stylesheet'
themeLink.href = `https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/${themeName}.min.css`
document.head.appendChild(themeLink)
}
function onThemeChange(event: CustomEvent) {
currentTheme.value = event.detail
loadTheme(event.detail)
if (monacoInstance.value && monacoEditor.value) {
const monacoTheme = monacoThemeMap[event.detail] || 'vs-dark'
monacoInstance.value.editor.setTheme(monacoTheme)
}
}
function onThemeSelect() {
loadTheme(currentTheme.value)
localStorage.setItem('ams_code_theme', currentTheme.value)
if (monacoInstance.value && monacoEditor.value) {
const monacoTheme = monacoThemeMap[currentTheme.value] || 'vs-dark'
monacoInstance.value.editor.setTheme(monacoTheme)
}
}
// Define custom Monaco themes
async function defineCustomThemes(monaco: any) {
monaco.editor.defineTheme('monokai', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'comment', foreground: '88846f' },
{ token: 'keyword', foreground: 'f92672' },
{ token: 'string', foreground: 'e6db74' },
{ token: 'number', foreground: 'ae81ff' },
{ token: 'type', foreground: '66d9ef' },
{ token: 'function', foreground: 'a6e22e' },
{ token: 'variable', foreground: 'f8f8f2' },
],
colors: {
'editor.background': '#272822',
'editor.foreground': '#f8f8f2',
'editorLineNumber.foreground': '#90908a',
'editor.selectionBackground': '#49483e',
'editor.lineHighlightBackground': '#3e3d32',
}
})
monaco.editor.defineTheme('dracula', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'comment', foreground: '6272a4' },
{ token: 'keyword', foreground: 'ff79c6' },
{ token: 'string', foreground: 'f1fa8c' },
{ token: 'number', foreground: 'bd93f9' },
{ token: 'type', foreground: '8be9fd' },
{ token: 'function', foreground: '50fa7b' },
{ token: 'variable', foreground: 'f8f8f2' },
],
colors: {
'editor.background': '#282a36',
'editor.foreground': '#f8f8f2',
'editorLineNumber.foreground': '#6272a4',
'editor.selectionBackground': '#44475a',
'editor.lineHighlightBackground': '#44475a',
}
})
monaco.editor.defineTheme('nord', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'comment', foreground: '616e88' },
{ token: 'keyword', foreground: '81a1c1' },
{ token: 'string', foreground: 'a3be8c' },
{ token: 'number', foreground: 'b48ead' },
{ token: 'type', foreground: '8fbcbb' },
{ token: 'function', foreground: '88c0d0' },
{ token: 'variable', foreground: 'd8dee9' },
],
colors: {
'editor.background': '#2e3440',
'editor.foreground': '#d8dee9',
'editorLineNumber.foreground': '#4c566a',
'editor.selectionBackground': '#434c5e',
'editor.lineHighlightBackground': '#3b4252',
}
})
monaco.editor.defineTheme('night-owl', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'comment', foreground: '637777' },
{ token: 'keyword', foreground: 'c792ea' },
{ token: 'string', foreground: 'ecc48d' },
{ token: 'number', foreground: 'f78c6c' },
{ token: 'type', foreground: 'ffcb8b' },
{ token: 'function', foreground: '82aaff' },
{ token: 'variable', foreground: 'd6deeb' },
],
colors: {
'editor.background': '#011627',
'editor.foreground': '#d6deeb',
'editorLineNumber.foreground': '#4b6479',
'editor.selectionBackground': '#1d3b53',
'editor.lineHighlightBackground': '#0b2942',
}
})
}
// Initialize Monaco editor
async function initMonaco() {
if (!editorContainer.value) return
const monaco = await loader.init()
monacoInstance.value = monaco
await defineCustomThemes(monaco)
const monacoTheme = monacoThemeMap[currentTheme.value] || 'vs-dark'
monacoEditor.value = monaco.editor.create(editorContainer.value, {
value: fileContent.value,
language: getMonacoLanguage(currentFile.value?.name || ''),
theme: monacoTheme,
automaticLayout: true,
minimap: { enabled: true },
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'off',
tabSize: 2,
insertSpaces: true,
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },
cursorBlinking: 'smooth',
smoothScrolling: true,
padding: { top: 16, bottom: 16 },
suggestOnTriggerCharacters: true,
quickSuggestions: true,
acceptSuggestionOnEnter: 'on',
tabCompletion: 'on',
wordBasedSuggestions: 'currentDocument',
parameterHints: { enabled: true },
folding: true,
foldingStrategy: 'indentation',
showFoldingControls: 'mouseover',
formatOnPaste: true,
formatOnType: true,
})
monacoEditor.value.onDidChangeModelContent(() => {
fileContent.value = monacoEditor.value.getValue()
})
monacoEditor.value.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
if (hasChanges.value) {
openSaveDialog()
}
})
}
function destroyMonaco() {
if (monacoEditor.value) {
monacoEditor.value.dispose()
monacoEditor.value = null
}
}
function getMonacoLanguage(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || ''
const langMap: Record<string, string> = {
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
vue: 'html', html: 'html', css: 'css', scss: 'scss', less: 'less',
json: 'json', md: 'markdown', py: 'python', rs: 'rust', go: 'go',
sh: 'shell', bash: 'shell', yaml: 'yaml', yml: 'yaml', sql: 'sql',
dockerfile: 'dockerfile', xml: 'xml', c: 'c', cpp: 'cpp', h: 'c',
hpp: 'cpp', cs: 'csharp', java: 'java', rb: 'ruby', php: 'php',
swift: 'swift', kt: 'kotlin', scala: 'scala', r: 'r', lua: 'lua',
perl: 'perl', pl: 'perl',
}
return langMap[ext] || 'plaintext'
}
// LocalStorage keys for remembering state
const STORAGE_KEYS = {
project: 'ams_gitlab_project_id',
branch: 'ams_gitlab_branch',
file: 'ams_gitlab_file_path',
scrollTop: 'ams_gitlab_scroll_top',
scrollLeft: 'ams_gitlab_scroll_left',
expandedPrefix: 'ams_gitlab_expanded_'
}
// Helper functions for expanded keys storage
function saveExpandedKeys(projectId: number, keys: Record<string, boolean>) {
localStorage.setItem(STORAGE_KEYS.expandedPrefix + projectId, JSON.stringify(keys))
}
function loadExpandedKeys(projectId: number): Record<string, boolean> {
const saved = localStorage.getItem(STORAGE_KEYS.expandedPrefix + projectId)
return saved ? JSON.parse(saved) : {}
}
// Flag to track if we're restoring state
const isRestoring = ref(false)
// Refs for scroll position
const editorContentRef = ref<HTMLElement | null>(null)
// Debounced scroll position saver
let scrollSaveTimeout: number | null = null
function saveScrollPosition() {
if (scrollSaveTimeout) clearTimeout(scrollSaveTimeout)
scrollSaveTimeout = window.setTimeout(() => {
if (editorContentRef.value) {
localStorage.setItem(STORAGE_KEYS.scrollTop, editorContentRef.value.scrollTop.toString())
localStorage.setItem(STORAGE_KEYS.scrollLeft, editorContentRef.value.scrollLeft.toString())
}
}, 300)
}
function restoreScrollPosition() {
const savedTop = localStorage.getItem(STORAGE_KEYS.scrollTop)
const savedLeft = localStorage.getItem(STORAGE_KEYS.scrollLeft)
setTimeout(() => {
if (editorContentRef.value) {
if (savedTop) editorContentRef.value.scrollTop = parseInt(savedTop, 10)
if (savedLeft) editorContentRef.value.scrollLeft = parseInt(savedLeft, 10)
}
}, 200)
}
import { onUnmounted } from 'vue'
onUnmounted(() => {
window.removeEventListener('code-theme-changed', onThemeChange as EventListener)
if (themeLink) themeLink.remove()
destroyMonaco()
})
interface Project {
id: number
name: string
fullName: string
path: string
description: string | null
defaultBranch: string
webUrl: string
lastActivity: string
}
interface Branch {
name: string
isDefault: boolean
isProtected: boolean
lastCommit: { id: string; shortId: string; title: string; author: string; date: string }
}
interface Commit {
id: string
shortId: string
title: string
message: string
author: string
authorEmail: string
date: string
webUrl: string
}
interface TreeNode {
key: string
label: string
icon: string
data: { path: string; type: 'folder' | 'file' }
children?: TreeNode[]
leaf?: boolean
}
interface FileContent {
name: string
path: string
size: number
content: string
branch: string
}
// State
const loading = ref(false)
const projects = ref<Project[]>([])
const selectedProject = ref<Project | null>(null)
const branches = ref<Branch[]>([])
const selectedBranch = ref<Branch | null>(null)
const commits = ref<Commit[]>([])
const treeNodes = ref<TreeNode[]>([])
const expandedKeys = ref<Record<string, boolean>>({})
const treeKey = ref(0)
// File editor state
const currentFile = ref<FileContent | null>(null)
const fileContent = ref('')
const originalContent = ref('')
const fileLoading = ref(false)
const showSaveDialog = ref(false)
const commitMessage = ref('')
const saving = ref(false)
const isEditing = ref(false)
const codeRef = ref<HTMLElement | null>(null)
// Commit detail state
const showCommitDialog = ref(false)
const selectedCommit = ref<any>(null)
const commitLoading = ref(false)
const hasChanges = computed(() => fileContent.value !== originalContent.value)
function getLanguage(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || ''
const langMap: Record<string, string> = {
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
vue: 'xml', html: 'xml', css: 'css', scss: 'scss', json: 'json',
md: 'markdown', py: 'python', rs: 'rust', go: 'go', sh: 'bash',
yaml: 'yaml', yml: 'yaml', sql: 'sql', dockerfile: 'dockerfile'
}
return langMap[ext] || 'plaintext'
}
const highlightedCode = computed(() => {
if (!currentFile.value || !fileContent.value) return ''
const lang = getLanguage(currentFile.value.name)
try {
if (hljs.getLanguage(lang)) {
return hljs.highlight(fileContent.value, { language: lang }).value
}
return hljs.highlightAuto(fileContent.value).value
} catch {
return fileContent.value.replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
})
async function startEditing() {
isEditing.value = true
await nextTick()
await initMonaco()
}
function cancelEditing() {
isEditing.value = false
fileContent.value = originalContent.value
destroyMonaco()
}
function getBranchLabel(branch: Branch): string {
return branch.name + (branch.isDefault ? ' (default)' : '') + (branch.isProtected ? ' 🔒' : '')
}
onMounted(async () => {
loadTheme(currentTheme.value)
window.addEventListener('code-theme-changed', onThemeChange as EventListener)
await loadProjects()
// Restore last selected project
const savedProjectId = localStorage.getItem(STORAGE_KEYS.project)
if (savedProjectId) {
const project = projects.value.find(p => p.id === parseInt(savedProjectId))
if (project) {
isRestoring.value = true
selectedProject.value = project
}
}
})
// Watch for project change
watch(selectedProject, async (project, oldProject) => {
if (project) {
// Save to localStorage
if (!isRestoring.value) {
localStorage.setItem(STORAGE_KEYS.project, project.id.toString())
}
// Load expanded keys for this project
expandedKeys.value = loadExpandedKeys(project.id)
treeKey.value++
await loadBranches(project.id)
} else {
branches.value = []
selectedBranch.value = null
}
})
// Watch for branch change
watch(selectedBranch, async (branch) => {
if (branch && selectedProject.value) {
// Save to localStorage
if (!isRestoring.value) {
localStorage.setItem(STORAGE_KEYS.branch, branch.name)
}
const [, rootNodes] = await Promise.all([
loadCommits(selectedProject.value.id, branch.name),
loadTree(selectedProject.value.id, branch.name, '')
])
treeNodes.value = rootNodes
// Restore expanded keys after tree is loaded
await nextTick()
if (!isRestoring.value) {
const savedKeys = loadExpandedKeys(selectedProject.value.id)
if (Object.keys(savedKeys).length > 0) {
expandedKeys.value = { ...savedKeys }
}
}
// Restore file if we're restoring state
if (isRestoring.value) {
const savedFilePath = localStorage.getItem(STORAGE_KEYS.file)
if (savedFilePath) {
await loadFile(selectedProject.value.id, branch.name, savedFilePath)
restoreScrollPosition()
}
isRestoring.value = false
}
} else {
commits.value = []
treeNodes.value = []
}
// Clear current file when branch changes (unless restoring)
if (!isRestoring.value) {
currentFile.value = null
fileContent.value = ''
originalContent.value = ''
destroyMonaco()
isEditing.value = false
}
})
// Watch expandedKeys to save them
watch(expandedKeys, (keys) => {
if (selectedProject.value && !isRestoring.value) {
saveExpandedKeys(selectedProject.value.id, keys)
}
}, { deep: true })
async function loadProjects() {
loading.value = true
try {
projects.value = await api.get('/gitlab/projects')
} catch (error: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Projekte konnten nicht geladen werden', life: 3000 })
} finally {
loading.value = false
}
}
async function loadBranches(projectId: number) {
loading.value = true
try {
branches.value = await api.get(`/gitlab/projects/${projectId}/branches`)
// Restore saved branch or use default
const savedBranch = localStorage.getItem(STORAGE_KEYS.branch)
let branchToSelect = null
if (isRestoring.value && savedBranch) {
branchToSelect = branches.value.find(b => b.name === savedBranch)
}
if (!branchToSelect) {
branchToSelect = branches.value.find(b => b.isDefault) || branches.value[0]
}
if (branchToSelect) {
selectedBranch.value = branchToSelect
}
} catch (error: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Branches konnten nicht geladen werden', life: 3000 })
} finally {
loading.value = false
}
}
async function loadCommits(projectId: number, branch: string) {
try {
commits.value = await api.get(`/gitlab/projects/${projectId}/commits?branch=${encodeURIComponent(branch)}&per_page=50`)
} catch (error: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Commits konnten nicht geladen werden', life: 3000 })
}
}
async function loadTree(projectId: number, branch: string, path: string): Promise<TreeNode[]> {
try {
const pathParam = path ? `&path=${encodeURIComponent(path)}` : ''
const items: any[] = await api.get(`/gitlab/projects/${projectId}/tree?branch=${encodeURIComponent(branch)}${pathParam}`)
return items.map((item: any) => ({
key: item.path,
label: item.name,
icon: item.type === 'folder' ? 'pi pi-folder' : getFileIcon(item.name),
data: { path: item.path, type: item.type },
leaf: item.type === 'file',
children: item.type === 'folder' ? [] : undefined
}))
} catch (error: any) {
return []
}
}
function getFileIcon(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase()
switch (ext) {
case 'ts': case 'tsx': case 'js': case 'jsx': case 'vue': return 'pi pi-code'
case 'json': case 'md': return 'pi pi-file'
case 'css': case 'scss': return 'pi pi-palette'
case 'html': return 'pi pi-globe'
case 'png': case 'jpg': case 'jpeg': case 'gif': case 'svg': return 'pi pi-image'
default: return 'pi pi-file'
}
}
function formatDiff(diffText: string, filename: string): string {
if (!diffText) return ''
const lines = diffText.split('\n')
return lines.map(line => {
const escaped = line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
if (line.startsWith('@@')) return `<div class="diff-line diff-hunk">${escaped}</div>`
if (line.startsWith('+')) return `<div class="diff-line diff-add">${escaped}</div>`
if (line.startsWith('-')) return `<div class="diff-line diff-remove">${escaped}</div>`
return `<div class="diff-line diff-context">${escaped}</div>`
}).join('')
}
async function onNodeExpand(node: any) {
if (node.data.type === 'folder' && (!node.children || node.children.length === 0)) {
if (selectedProject.value && selectedBranch.value) {
node.children = await loadTree(selectedProject.value.id, selectedBranch.value.name, node.data.path)
}
}
}
async function onNodeSelect(node: any) {
if (node.data.type === 'file' && selectedProject.value && selectedBranch.value) {
if (isEditing.value) {
destroyMonaco()
isEditing.value = false
}
await loadFile(selectedProject.value.id, selectedBranch.value.name, node.data.path)
// Save file path to localStorage
localStorage.setItem(STORAGE_KEYS.file, node.data.path)
}
}
async function loadFile(projectId: number, branch: string, path: string) {
fileLoading.value = true
try {
const file: FileContent = await api.get(`/gitlab/projects/${projectId}/file?branch=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}`)
currentFile.value = file
fileContent.value = file.content
originalContent.value = file.content
} catch (error: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Datei konnte nicht geladen werden', life: 3000 })
} finally {
fileLoading.value = false
}
}
function openSaveDialog() {
commitMessage.value = `Update ${currentFile.value?.name}`
showSaveDialog.value = true
}
async function saveFile() {
if (!currentFile.value || !selectedProject.value || !selectedBranch.value || !commitMessage.value) return
saving.value = true
try {
await api.put(`/gitlab/projects/${selectedProject.value.id}/file`, {
path: currentFile.value.path,
branch: selectedBranch.value.name,
content: fileContent.value,
commitMessage: commitMessage.value
})
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Datei wurde committed', life: 3000 })
originalContent.value = fileContent.value
showSaveDialog.value = false
await loadCommits(selectedProject.value.id, selectedBranch.value.name)
} catch (error: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Datei konnte nicht gespeichert werden', life: 3000 })
} finally {
saving.value = false
}
}
function discardChanges() {
fileContent.value = originalContent.value
if (monacoEditor.value) {
monacoEditor.value.setValue(originalContent.value)
}
}
async function viewCommit(commit: Commit) {
if (!selectedProject.value) return
commitLoading.value = true
showCommitDialog.value = true
try {
selectedCommit.value = await api.get(`/gitlab/projects/${selectedProject.value.id}/commit/${commit.id}`)
} catch (error: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Commit-Details konnten nicht geladen werden', life: 3000 })
} finally {
commitLoading.value = false
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
})
}
</script>
<template>
<div class="gitlab-view">
<!-- Header -->
<div class="header">
<h1><i class="pi pi-github"></i> GitLab</h1>
<div class="controls">
<Dropdown
v-model="selectedProject"
:options="projects"
optionLabel="fullName"
placeholder="Projekt auswählen..."
:loading="loading"
class="project-dropdown"
filter
filterPlaceholder="Suchen..."
/>
<Dropdown
v-model="selectedBranch"
:options="branches"
:optionLabel="getBranchLabel"
placeholder="Branch..."
:disabled="!selectedProject"
class="branch-dropdown"
/>
<Button
v-if="selectedProject"
icon="pi pi-external-link"
label="GitLab öffnen"
severity="secondary"
size="small"
@click="window.open(selectedProject.webUrl, '_blank')"
/>
</div>
</div>
<!-- Main Content -->
<div v-if="selectedProject && selectedBranch" class="main-content">
<Splitter class="main-splitter">
<!-- Left: File Tree + Commits -->
<SplitterPanel :size="30" :minSize="20">
<TabView>
<TabPanel header="Dateien">
<div class="file-tree">
<Tree
:key="treeKey"
:value="treeNodes"
v-model:expandedKeys="expandedKeys"
selectionMode="single"
@nodeExpand="onNodeExpand"
@nodeSelect="onNodeSelect"
class="w-full"
/>
</div>
</TabPanel>
<TabPanel header="Commits">
<div class="commits-list">
<div
v-for="commit in commits"
:key="commit.id"
class="commit-item"
@click="viewCommit(commit)"
>
<div class="commit-title">{{ commit.title }}</div>
<div class="commit-meta">
<span class="commit-id">{{ commit.shortId }}</span>
<span class="commit-author">{{ commit.author }}</span>
<span class="commit-date">{{ formatDate(commit.date) }}</span>
</div>
</div>
</div>
</TabPanel>
</TabView>
</SplitterPanel>
<!-- Right: File Editor -->
<SplitterPanel :size="70" :minSize="40">
<div class="editor-panel">
<template v-if="currentFile">
<div class="editor-header">
<div class="file-info">
<i :class="getFileIcon(currentFile.name)"></i>
<span class="file-path">{{ currentFile.path }}</span>
<span v-if="hasChanges" class="unsaved-badge"></span>
</div>
<div class="editor-actions">
<select v-model="currentTheme" @change="onThemeSelect" class="theme-select-inline">
<option v-for="theme in codeThemes" :key="theme.value" :value="theme.value">
{{ theme.label }}
</option>
</select>
<Button v-if="!isEditing" icon="pi pi-pencil" label="Bearbeiten" severity="secondary" size="small" @click="startEditing" />
<Button v-if="isEditing" icon="pi pi-times" label="Abbrechen" severity="secondary" size="small" @click="cancelEditing" />
<Button v-if="isEditing && hasChanges" icon="pi pi-save" label="Committen" size="small" @click="openSaveDialog" />
</div>
</div>
<div class="editor-content" ref="editorContentRef" @scroll="saveScrollPosition">
<div v-if="isEditing" ref="editorContainer" class="monaco-container"></div>
<pre v-else class="code-view"><code ref="codeRef" v-html="highlightedCode"></code></pre>
</div>
</template>
<template v-else-if="fileLoading">
<div class="loading-state">
<ProgressSpinner />
<p>Datei wird geladen...</p>
</div>
</template>
<template v-else>
<div class="empty-state">
<i class="pi pi-file"></i>
<p>Wähle eine Datei aus dem Baum</p>
</div>
</template>
</div>
</SplitterPanel>
</Splitter>
</div>
<!-- No project selected -->
<div v-else class="empty-state large">
<i class="pi pi-github"></i>
<h2>GitLab Projekte</h2>
<p>Wähle ein Projekt und Branch aus um loszulegen</p>
</div>
<!-- Save Dialog -->
<Dialog v-model:visible="showSaveDialog" header="Änderungen committen" :style="{ width: '90vw', maxWidth: '500px' }" modal appendTo="self">
<div class="save-dialog-content">
<label>Commit-Nachricht:</label>
<InputText v-model="commitMessage" class="w-full" />
</div>
<template #footer>
<Button label="Abbrechen" severity="secondary" @click="showSaveDialog = false" />
<Button label="Committen" icon="pi pi-check" :loading="saving" @click="saveFile" />
</template>
</Dialog>
<!-- Commit Detail Dialog -->
<Dialog v-model:visible="showCommitDialog" header="Commit Details" :style="{ width: '90vw', maxWidth: '900px' }" :contentStyle="{ maxHeight: '75vh', overflow: 'auto' }" modal appendTo="self">
<template v-if="commitLoading">
<div class="loading-state"><ProgressSpinner /></div>
</template>
<template v-else-if="selectedCommit">
<div class="commit-detail">
<div class="commit-header">
<h3>{{ selectedCommit.title }}</h3>
<p class="commit-message" v-if="selectedCommit.message !== selectedCommit.title">{{ selectedCommit.message }}</p>
<div class="commit-meta-detail">
<span><i class="pi pi-user"></i> {{ selectedCommit.author }}</span>
<span><i class="pi pi-calendar"></i> {{ formatDate(selectedCommit.date) }}</span>
<span><i class="pi pi-hashtag"></i> {{ selectedCommit.shortId }}</span>
</div>
<div class="commit-stats" v-if="selectedCommit.stats">
<span class="additions">+{{ selectedCommit.stats.additions }}</span>
<span class="deletions">-{{ selectedCommit.stats.deletions }}</span>
</div>
</div>
<div class="commit-diff" v-if="selectedCommit.diff">
<div v-for="(file, idx) in selectedCommit.diff" :key="idx" class="diff-file">
<div class="diff-file-header"><span class="diff-file-name">{{ file.newPath }}</span></div>
<div class="diff-content" v-html="formatDiff(file.diff, file.newPath)"></div>
</div>
</div>
</div>
</template>
</Dialog>
</div>
</template>
<style scoped>
.gitlab-view {
padding: 0.5rem;
height: 100%;
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
flex-wrap: wrap;
gap: 0.75rem;
}
.header h1 {
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.controls {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.project-dropdown { min-width: 300px; }
.branch-dropdown { min-width: 200px; }
.main-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.main-splitter {
flex: 1;
min-height: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: #0d1117;
}
.file-tree {
height: calc(100vh - 170px);
overflow: auto;
padding: 0;
background: #0d1117 !important;
}
.file-tree * { background-color: transparent; }
.commits-list {
height: calc(100vh - 170px);
overflow: auto;
background: #0d1117;
}
.commit-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: background 0.2s;
}
.commit-item:hover { background: rgba(255, 255, 255, 0.08); }
.commit-title {
font-weight: 500;
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #e6edf3;
}
.commit-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #8b949e;
}
.commit-id {
font-family: monospace;
background: rgba(255, 255, 255, 0.1);
padding: 0.1rem 0.4rem;
border-radius: 4px;
color: #79c0ff;
}
.commit-author { color: #e6edf3; }
.commit-date { color: #8b949e; }
.editor-panel {
height: 100%;
display: flex;
flex-direction: column;
background: #0d1117;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background: #161b22;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.file-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.file-path {
font-family: monospace;
font-size: 0.875rem;
}
.unsaved-badge {
color: var(--primary-color);
font-size: 1.5rem;
line-height: 1;
}
.editor-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.theme-select-inline {
padding: 0.4rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #e6edf3;
font-size: 0.8rem;
cursor: pointer;
min-width: 140px;
}
.theme-select-inline option {
background: #161b22;
color: #e6edf3;
}
.theme-select-inline:hover { border-color: rgba(255, 255, 255, 0.3); }
.theme-select-inline:focus { outline: none; border-color: #6366f1; }
.editor-content {
flex: 1;
min-height: 0;
overflow: auto;
}
.monaco-container {
width: 100%;
height: 100%;
min-height: 500px;
}
.code-view {
margin: 0;
padding: 1rem;
height: 100%;
overflow: auto;
background: #0d1117;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.875rem;
line-height: 1.6;
tab-size: 2;
}
.code-view code { font-family: inherit; padding: 0; }
.code-view .hljs { background: transparent !important; }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #8b949e;
}
.empty-state i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state.large { height: calc(100vh - 150px); }
.empty-state.large i { font-size: 5rem; }
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
gap: 1rem;
}
.save-dialog-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.commit-detail .commit-header { margin-bottom: 1rem; }
.commit-detail h3 { margin: 0 0 0.5rem 0; }
.commit-message { white-space: pre-wrap; color: #8b949e; margin: 0.5rem 0; }
.commit-meta-detail {
display: flex;
gap: 1.5rem;
font-size: 0.875rem;
color: #8b949e;
margin: 0.75rem 0;
}
.commit-meta-detail i { margin-right: 0.25rem; }
.commit-stats { display: flex; gap: 1rem; font-family: monospace; }
.commit-stats .additions { color: #22c55e; }
.commit-stats .deletions { color: #ef4444; }
.diff-file {
margin-bottom: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
overflow: hidden;
}
.diff-file-header {
background: #161b22;
padding: 0.5rem 1rem;
font-family: monospace;
font-size: 0.875rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
color: #e6edf3;
}
.diff-content {
background: #0d1117;
color: #e6edf3;
padding: 0;
margin: 0;
overflow-x: auto;
font-size: 0.8rem;
line-height: 1;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
/* v-html generated content needs :deep() to receive scoped styles */
.diff-content :deep(.diff-line) {
padding: 0.15rem 1rem;
white-space: pre;
border-left: 3px solid transparent;
}
.diff-content :deep(.diff-hunk) {
background: rgba(56, 139, 253, 0.15);
color: #79c0ff;
border-left-color: #388bfd;
padding: 0.3rem 1rem;
margin: 0.25rem 0;
}
.diff-content :deep(.diff-add) {
background: rgba(46, 160, 67, 0.2);
color: #7ee787;
border-left-color: #2ea043;
}
.diff-content :deep(.diff-remove) {
background: rgba(248, 81, 73, 0.2);
color: #ffa198;
border-left-color: #f85149;
}
.diff-content :deep(.diff-context) { color: #8b949e; }
.diff-file-name { color: #e6edf3; }
/* PrimeVue Dark Theme Overrides */
:deep(.p-tabview) { background: #0d1117; }
:deep(.p-tabview-panels) { background: #0d1117; padding: 0; }
:deep(.p-tabview-nav) { background: #161b22; border-bottom: 1px solid rgba(255, 255, 255, 0.1); }
:deep(.p-tabview-nav li) { margin: 0; }
:deep(.p-tabview-nav li .p-tabview-nav-link) { background: transparent; color: #8b949e; border: none; padding: 0.75rem 1.25rem; font-weight: 500; }
:deep(.p-tabview-nav li .p-tabview-nav-link:hover) { background: rgba(255, 255, 255, 0.05); color: #e6edf3; }
:deep(.p-tabview-nav li.p-highlight .p-tabview-nav-link) { background: transparent; color: #58a6ff; border-bottom: 2px solid #58a6ff; }
:deep(.p-tree) { background: #0d1117 !important; border: none !important; padding: 0.5rem !important; color: #e6edf3 !important; }
:deep(.p-tree-root), :deep(.p-tree-root-children) { background: #0d1117 !important; }
:deep(.p-treenode) { background: transparent !important; }
:deep(.p-treenode-content) { padding: 0.35rem 0.5rem !important; border-radius: 6px !important; background: transparent !important; color: #e6edf3 !important; }
:deep(.p-treenode-content:hover) { background: rgba(255, 255, 255, 0.08) !important; }
:deep(.p-treenode-content.p-highlight) { background: rgba(88, 166, 255, 0.2) !important; }
:deep(.p-tree-toggler) { color: #8b949e !important; background: transparent !important; }
:deep(.p-tree-toggler:hover) { background: rgba(255, 255, 255, 0.1) !important; color: #e6edf3 !important; }
:deep(.p-treenode-icon) { color: #8b949e !important; }
:deep(.p-treenode-label) { color: #e6edf3 !important; }
:deep(.p-tree-wrapper) { background: #0d1117 !important; }
:deep(.p-splitter) { background: #0d1117; border: none; }
:deep(.p-splitter-gutter) { background: #21262d; }
:deep(.p-splitter-gutter:hover) { background: #30363d; }
:deep(.p-dialog) { background: #161b22; border: 1px solid rgba(255, 255, 255, 0.1); }
:deep(.p-dialog-header) { background: #161b22; color: #e6edf3; border-bottom: 1px solid rgba(255, 255, 255, 0.1); }
:deep(.p-dialog-content) { background: #161b22; color: #e6edf3; }
:deep(.p-dialog-footer) { background: #161b22; border-top: 1px solid rgba(255, 255, 255, 0.1); }
:deep(.p-inputtext) { background: #0d1117; border: 1px solid rgba(255, 255, 255, 0.2); color: #e6edf3; }
:deep(.p-inputtext:focus) { border-color: #58a6ff; box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.2); }
</style>