1187 lines
37 KiB
Vue
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, '<').replace(/>/g, '>')
|
|
}
|
|
})
|
|
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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>
|