Git Repository
Stackyard
Stackyard scannt lokale Entwicklungsordner und bündelt Projekte, Git-Status, TODOs, Toolchains, Docker-Compose-Setups, Datei- und Launcher-Funktionen sowie Operations-Informationen in einer lokalen WebUI.
HTTPS
https://zanvex.de/git/stackyard.gitinternal/web/project_templates.go
Zum Verzeichnispackage web
import "html/template"
var projectTemplate = template.Must(template.New("project").Parse(`<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Project.Name}} · Stackyard</title>
<style>
:root { color-scheme:dark; --bg:#080d18; --panel:#111827; --panel2:#0c1322; --line:#253149; --text:#e8eef8; --muted:#91a0b8; --accent:#38bdf8; --accent2:#0ea5e9; --good:#4ade80; --warn:#fbbf24; }
* { box-sizing:border-box } body { margin:0; font:15px/1.55 "Segoe UI",system-ui,sans-serif; color:var(--text); background:radial-gradient(circle at 15% 0,#13213a 0,transparent 35rem),var(--bg) }
a { color:inherit; text-decoration:none } button,input,select,textarea { font:inherit } button,.button { display:inline-flex; align-items:center; justify-content:center; min-height:2.5rem; padding:.58rem .85rem; border:1px solid #155e75; border-radius:.65rem; color:#ecfeff; background:#0e7490; cursor:pointer } button:hover,.button:hover { background:#0891b2 }
.shell { max-width:1440px; margin:auto; padding:1.25rem 1.5rem 3rem }.crumb { display:inline-block; color:var(--muted); margin-bottom:.8rem }.crumb:hover { color:white }
.hero,.card { border:1px solid var(--line); background:rgba(17,24,39,.94); box-shadow:0 18px 50px rgba(0,0,0,.18) }.hero { border-radius:1rem; padding:1.25rem 1.4rem; margin-bottom:1rem }.hero-row,.section-head,.actions,.meta,.status-form { display:flex; align-items:center; flex-wrap:wrap; gap:.65rem }.hero-row { justify-content:space-between; align-items:flex-start }.eyebrow { color:var(--accent); font-size:.78rem; font-weight:700; letter-spacing:.12em; text-transform:uppercase }.hero h1 { margin:.12rem 0 .15rem; font-size:clamp(1.65rem,3vw,2.35rem); line-height:1.15 }.path { color:var(--muted); font: .84rem ui-monospace,SFMono-Regular,Consolas,monospace; overflow-wrap:anywhere }.meta { margin-top:.8rem }.pill { border:1px solid var(--line); border-radius:999px; padding:.2rem .55rem; color:#cbd5e1; background:#0b1220; font-size:.78rem }.health-pill-good { color:#bbf7d0; border-color:#166534 }.health-pill-warning { color:#fde68a; border-color:#854d0e }.health-pill-muted { color:#cbd5e1 }.state-paused{color:#fde68a;border-color:#854d0e}.state-archived{color:#cbd5e1;border-color:#475569}.state-experimental{color:#d8b4fe;border-color:#7e22ce}
.actions { margin-top:1rem }.actions .primary { background:var(--accent2); border-color:#0284c7 }.status-form select { min-height:2.5rem; color:var(--text); background:#0b1220; border:1px solid var(--line); border-radius:.65rem; padding:.55rem .75rem }
.flash { margin:0 0 1rem; padding:.75rem 1rem; border-radius:.7rem; border:1px solid }.flash-success { color:#bbf7d0; background:#052e16; border-color:#166534 }.flash-error { color:#fecaca; background:#450a0a; border-color:#991b1b }
.grid { display:grid; grid-template-columns:minmax(0,1.65fr) minmax(19rem,.75fr); gap:1rem }.stack { display:grid; gap:1rem; align-content:start }.card { border-radius:.9rem; padding:1rem 1.1rem; min-width:0 }.section-head { justify-content:space-between; margin-bottom:.8rem }.section-head h2 { margin:0; font-size:1rem }.note { color:var(--muted); font-size:.82rem }.scroll { max-height:32rem; overflow:auto; padding-right:.45rem }.readme { line-height:1.65 }.readme h1 { font-size:1.55rem }.readme h2 { font-size:1.2rem; margin-top:1.5rem }.readme pre { overflow:auto; padding:.85rem; background:#070b13; border:1px solid var(--line); border-radius:.6rem }.readme p,.readme ul { margin:.65rem 0 }
#todo-panel textarea,.metadata textarea,.metadata input,.inline-form input,.inline-form select { width:100%; resize:vertical; color:var(--text); background:#080d18; border:1px solid var(--line); border-radius:.65rem; padding:.7rem }.metadata textarea{min-height:7rem}.metadata label{display:block;color:var(--muted);font-size:.8rem;margin:.65rem 0 .25rem}.list{list-style:none;padding:0;margin:0}.list li{padding:.55rem 0;border-bottom:1px solid var(--line)}.file-tree ul { list-style:none; margin:0; padding-left:1rem }.file-tree>ul { padding-left:0 }.file-tree li { margin:.18rem 0 }.tree-dir>summary { cursor:pointer }.tree-dir>summary::marker { color:var(--muted) }.tree-file { color:#aebbd0 }.empty { color:var(--muted) }
@media(max-width:900px){.shell{padding:1rem}.grid{grid-template-columns:1fr}.hero-row{display:block}.actions{margin-top:1rem}}
</style>
</head>
<body><main class="shell">
<a class="crumb" href="/">← Alle Projekte</a>
{{if .Flash.Text}}<div class="flash flash-{{.Flash.Kind}}">{{.Flash.Text}}</div>{{end}}
<header class="hero">
<div class="hero-row"><div><div class="eyebrow">{{.Project.RootName}} · {{.Project.PrimaryType}}</div><h1>{{.Project.Name}}</h1><div class="path">{{.Project.Path}}</div></div>
<div class="meta"><span class="pill health-pill-{{.Project.Health}}">{{.Project.HealthLabel}}</span><span class="pill state-{{.Project.State}}">{{.Project.State}}</span>{{if .Project.Git.IsRepo}}<span class="pill">Git · {{.Project.Git.Branch}}{{if .Project.Git.Dirty}} · dirty{{end}}{{if .Project.Git.Ahead}} · ↑{{.Project.Git.Ahead}}{{end}}{{if .Project.Git.Behind}} · ↓{{.Project.Git.Behind}}{{end}}</span>{{end}}</div>
{{if .Project.Warnings}}<div class="meta">{{range .Project.Warnings}}<a class="pill health-pill-warning" href="#health-details">{{.}}</a>{{end}}</div>{{end}}
</div>
<div class="actions">
<a class="button primary" href="/projects/{{.Project.ID}}/editor">WebIDE öffnen</a>
<a class="button" href="/projects/{{.Project.ID}}/download?format=zip">ZIP laden</a><a class="button" href="/projects/{{.Project.ID}}/download?format=tar.gz">TAR.GZ laden</a>
<form method="post" action="/projects/{{.Project.ID}}/open"><button name="action" value="vscode">VS Code</button> <button name="action" value="explorer">Explorer</button> <button name="action" value="terminal">Terminal</button></form>
<form method="post" action="/projects/favorite"><input type="hidden" name="id" value="{{.Project.ID}}"><input type="hidden" name="favorite" value="{{if .Project.Favorite}}0{{else}}1{{end}}"><input type="hidden" name="return_to" value="/projects/{{.Project.ID}}"><button>{{if .Project.Favorite}}Favorit entfernen{{else}}Favorisieren{{end}}</button></form>
<form class="status-form" method="post" action="/projects/state"><input type="hidden" name="id" value="{{.Project.ID}}"><input type="hidden" name="return_to" value="/projects/{{.Project.ID}}"><select name="state"><option value="active" {{if eq .Project.State "active"}}selected{{end}}>Active</option><option value="paused" {{if eq .Project.State "paused"}}selected{{end}}>Paused</option><option value="archived" {{if eq .Project.State "archived"}}selected{{end}}>Archived</option><option value="experimental" {{if eq .Project.State "experimental"}}selected{{end}}>Experimental</option></select><button>Status speichern</button></form>
</div>
</header>
<div class="grid"><div class="stack">
<section class="card"><div class="section-head"><h2>README</h2><span class="note">{{if .Project.ReadmePath}}README.md{{else}}Nicht vorhanden{{end}}</span></div><div class="readme scroll">{{if .ReadmeBody}}{{.ReadmeHTML}}{{else}}<p class="empty">Keine README gefunden.</p>{{end}}</div></section>
<section class="card"><div class="section-head"><h2>TODO</h2><span class="note">Projektaufgaben</span></div><div id="todo-panel" hx-get="/projects/{{.Project.ID}}/todo" hx-trigger="load">Lade …</div></section>
<section class="card metadata"><div class="section-head"><h2>Projektinformationen</h2><span class="note">Eigene Metadaten</span></div><form method="post" action="/projects/metadata"><input type="hidden" name="id" value="{{.Project.ID}}"><label>Beschreibung</label><input name="description" maxlength="500" value="{{.Project.Description}}" placeholder="Kurze Projektbeschreibung"><label>Labels, kommasepariert</label><input name="labels" value="{{.LabelsText}}" placeholder="kunde, backend, wichtig"><label>Notizen</label><textarea name="notes" maxlength="5000" placeholder="Interne Notizen zum Projekt">{{.Project.Notes}}</textarea><p><button>Informationen speichern</button></p></form></section>
</div><aside class="stack">
{{if .Manifest.Actions}}<section class="card"><div class="section-head"><h2>Sichere Aktionen</h2><a class="note" href="/operations">Operations →</a></div>{{range .Manifest.Actions}}<div class="actions"><strong>{{.Name}}</strong>{{if eq .Kind "go-run"}}<form method="post" action="/operations/actions/start"><input type="hidden" name="project_id" value="{{$.Project.ID}}"><input type="hidden" name="kind" value="{{.Kind}}"><input type="hidden" name="return_to" value="/projects/{{$.Project.ID}}"><button>Start</button></form><form method="post" action="/operations/actions/stop"><input type="hidden" name="project_id" value="{{$.Project.ID}}"><input type="hidden" name="kind" value="{{.Kind}}"><input type="hidden" name="return_to" value="/projects/{{$.Project.ID}}"><button>Stop</button></form>{{else}}<form method="post" action="/operations/actions/run"><input type="hidden" name="project_id" value="{{$.Project.ID}}"><input type="hidden" name="kind" value="{{.Kind}}"><input type="hidden" name="return_to" value="/projects/{{$.Project.ID}}"><button>Ausführen</button></form>{{end}}</div>{{if eq .Kind "go-run"}}<div hx-get="/operations/actions/status?project_id={{$.Project.ID}}&kind={{.Kind}}" hx-trigger="load, every 2s"><span class="note">Status wird geladen …</span></div>{{end}}{{end}}</section>{{end}}
{{if .Ports}}<section class="card"><div class="section-head"><h2>Dienste</h2><span class="note">Live-Zustand in Operations</span></div>{{range .Ports}}<p><strong>{{.Name}}</strong> · Port {{.Port}} {{if .Reachable}}<span class="pill health-pill-good">Erreichbar</span>{{else}}<span class="pill health-pill-warning">Offline</span>{{end}} {{if .Healthy}}<span class="pill health-pill-good">Healthy</span>{{else if .Detail}}<span class="note">{{.Detail}}</span>{{end}}</p>{{end}}</section>{{end}}
<section class="card" id="health-details"><div class="section-head"><h2>Projektstatus</h2><span class="note">{{.Project.HealthLabel}}</span></div>{{range .Project.Warnings}}<p>• {{.}}</p>{{else}}<p class="empty">Keine Warnungen.</p>{{end}}</section><div id="git-panel" hx-get="/projects/{{.Project.ID}}/git" hx-trigger="load">Lade Git-Informationen …</div>
<section class="card"><div class="section-head"><h2>Dateien</h2><a class="note" href="/projects/{{.Project.ID}}/editor">In WebIDE öffnen →</a></div><div id="files-panel" class="file-tree scroll" hx-get="/projects/{{.Project.ID}}/files" hx-trigger="load">Lade …</div></section>
</aside></div>
</main><script src="https://unpkg.com/htmx.org@1.9.12"></script></body></html>`))
var editorTemplate = template.Must(template.New("editor").Parse(`<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>WebIDE · {{.Project.Name}}</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.21/lib/codemirror.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.21/theme/material-darker.css">
<style>
:root{color-scheme:dark;--bg:#070b13;--panel:#101827;--line:#253149;--text:#e7edf7;--muted:#91a0b8;--accent:#0ea5e9}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 "Segoe UI",system-ui,sans-serif}a{color:inherit;text-decoration:none}.top{height:4rem;padding:.7rem 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;border-bottom:1px solid var(--line);background:#0c1320}.title strong{display:block;font-size:1rem}.title span,.muted{color:var(--muted);font-size:.8rem}.back{padding:.55rem .75rem;border:1px solid var(--line);border-radius:.55rem}.workspace{height:calc(100vh - 4rem);display:grid;grid-template-columns:300px minmax(0,1fr)}.sidebar{overflow:auto;border-right:1px solid var(--line);background:var(--panel);padding:.8rem}.sidebar h2{margin:.1rem .3rem .8rem;font-size:.75rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase}.side-search,.create-row input,.create-row select{width:100%;background:#080d18;color:var(--text);border:1px solid var(--line);border-radius:.45rem;padding:.45rem;margin-bottom:.5rem}.create-row{display:grid;grid-template-columns:1fr 5.5rem auto;gap:.3rem;margin-bottom:.7rem}.create-row input,.create-row select{margin:0;min-width:0}.tree ul{list-style:none;margin:0;padding-left:.85rem}.tree>ul{padding:0}.tree li{margin:.08rem 0}.tree details summary{cursor:pointer;color:#c9d5e7;padding:.18rem}.file{display:block;padding:.2rem .35rem;border-radius:.35rem;color:#aebbd0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.file:hover{background:#1e293b;color:white}.editor{min-width:0;display:flex;flex-direction:column}.bar{min-height:3.1rem;padding:.55rem .8rem;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:.75rem}.bar-actions{display:flex;gap:.4rem;align-items:center}.path{font: .82rem ui-monospace,Consolas,monospace;color:#cbd5e1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.flash{padding:.55rem .8rem;border-bottom:1px solid}.flash-success{color:#bbf7d0;background:#052e16}.flash-error{color:#fecaca;background:#450a0a}form{flex:1;display:flex;min-height:0;flex-direction:column}textarea{flex:1;width:100%;resize:none;border:0;outline:0;padding:1rem 1.2rem;background:#080d18;color:#dbeafe;font:14px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace;tab-size:4}button{border:1px solid #0284c7;border-radius:.5rem;background:var(--accent);color:white;padding:.48rem .8rem;cursor:pointer}.secondary{background:#111827;border-color:var(--line)}.welcome{margin:auto;max-width:34rem;text-align:center;color:var(--muted);padding:2rem}.welcome h1{color:var(--text);font-size:1.35rem}dialog{width:min(50rem,90vw);border:1px solid var(--line);border-radius:.7rem;background:#0c1320;color:var(--text)}dialog pre{max-height:60vh;overflow:auto;white-space:pre-wrap}.dirty{color:#fbbf24}.CodeMirror{flex:1;height:auto;background:#080d18;font:14px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}.CodeMirror-gutters{background:#0b1220;border-color:var(--line)}@media(max-width:720px){.workspace{grid-template-columns:1fr}.sidebar{display:none}.title span{display:none}}
</style></head><body>
<header class="top"><div class="title"><strong>{{.Project.Name}} / WebIDE</strong><span>{{.Project.Path}}</span></div><a class="back" href="/projects/{{.Project.ID}}">← Projektübersicht</a></header>
<main class="workspace"><aside class="sidebar"><h2>Dateien</h2><input id="file-search" class="side-search" type="search" placeholder="Dateien filtern …"><form class="create-row" method="post" action="/projects/{{.Project.ID}}/editor"><input type="hidden" name="mode" value="create"><input name="path" required placeholder="pfad/datei.go"><select name="kind"><option value="file">Datei</option><option value="folder">Ordner</option></select><button title="Anlegen">+</button></form><div class="tree">{{template "editor-tree" .Files}}</div></aside><section class="editor">
{{if .Flash.Text}}<div class="flash flash-{{.Flash.Kind}}">{{.Flash.Text}}</div>{{end}}
{{if .SelectedPath}}<form id="editor-form" method="post" action="/projects/{{.Project.ID}}/editor"><div class="bar"><span class="path">{{.SelectedPath}} <span id="dirty-label">{{if .Protected}}· Schreibgeschützt{{end}}</span></span><div class="bar-actions"><span class="muted" id="editor-stats"></span><button class="secondary" id="diff-button" type="button">Änderungen</button><button type="submit" {{if .Protected}}disabled{{end}}>Speichern · Ctrl+S</button></div></div><input type="hidden" name="path" value="{{.SelectedPath}}"><textarea id="code-editor" name="content" spellcheck="false" {{if .Protected}}readonly{{end}}>{{.Content}}</textarea></form>{{else}}<div class="welcome"><h1>Datei zum Bearbeiten auswählen</h1><p>Die WebIDE bearbeitet Textdateien direkt im Projekt. Binärdateien, Symlink-Ausbrüche, sensible Dateien und Dateien über 2 MiB werden blockiert.</p></div>{{end}}
</section></main><dialog id="diff-dialog"><h2>Änderungsübersicht</h2><pre id="diff-output"></pre><form method="dialog"><button>Schließen</button></form></dialog><script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.21/lib/codemirror.js"></script><script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.21/mode/meta.js"></script><script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.21/mode/go/go.js"></script><script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.21/mode/javascript/javascript.js"></script><script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.21/mode/python/python.js"></script><script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.21/mode/yaml/yaml.js"></script><script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.21/mode/markdown/markdown.js"></script><script>const search=document.getElementById('file-search');search?.addEventListener('input',()=>{const q=search.value.toLowerCase();document.querySelectorAll('.file').forEach(a=>a.closest('li').hidden=!a.textContent.toLowerCase().includes(q)&&!a.title.toLowerCase().includes(q))});const editor=document.getElementById('code-editor');const form=document.getElementById('editor-form');let cm=null;if(editor&&window.CodeMirror){const info=CodeMirror.findModeByFileName({{printf "%q" .SelectedPath}})||{};cm=CodeMirror.fromTextArea(editor,{lineNumbers:true,mode:info.mode||null,theme:'material-darker',indentUnit:4,indentWithTabs:true,lineWrapping:false,readOnly:editor.readOnly,extraKeys:{'Ctrl-S':()=>form.requestSubmit(),'Cmd-S':()=>form.requestSubmit()}});cm.on('change',()=>{editor.value=cm.getValue();editor.dispatchEvent(new Event('input'))})}let dirty=false;function stats(){if(!editor)return;const lines=editor.value.split('\n').length;document.getElementById('editor-stats').textContent=lines+' Zeilen · '+editor.value.length+' Zeichen'}editor?.addEventListener('input',()=>{dirty=editor.value!==editor.defaultValue;document.getElementById('dirty-label').textContent=dirty?'● Ungespeichert':'';document.getElementById('dirty-label').className=dirty?'dirty':'';stats()});stats();form?.addEventListener('submit',()=>dirty=false);addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue=''}});addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'&&form){e.preventDefault();form.requestSubmit()}});document.getElementById('diff-button')?.addEventListener('click',()=>{const before=editor.defaultValue.split('\n'),after=editor.value.split('\n');let changed=0;for(let i=0;i<Math.max(before.length,after.length);i++)if(before[i]!==after[i])changed++;document.getElementById('diff-output').textContent=changed+' geänderte Zeilen\n\nVorher: '+before.length+' Zeilen\nNachher: '+after.length+' Zeilen';document.getElementById('diff-dialog').showModal()});</script></body></html>
{{define "editor-tree"}}<ul>{{range .}}<li>{{if .IsDir}}<details open><summary>{{.Name}}</summary>{{template "editor-tree" .Children}}{{else}}<a class="file" href="?path={{.Path}}" title="{{.Path}}">{{.Name}}</a>{{end}}</li>{{end}}</ul>{{end}}`))