<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Browser Git + Markdown Hierarchy (vanilla)</title>
<link rel="manifest" href="manifest.json" />
<style>
:root{--bg:#0f1720;--panel:#0b1220;--muted:#9aa4b2;--accent:#4f46e5;--card:#0b1226}
html,body{height:100%;margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,"Helvetica Neue",Arial;background:linear-gradient(180deg,#071025 0%,#071827 100%);color:#e6eef6}
.app{display:grid;grid-template-columns:260px 1fr 420px;gap:12px;padding:12px;height:100vh;box-sizing:border-box}
.panel{background:var(--panel);border-radius:8px;padding:10px;box-shadow:0 6px 18px rgba(2,6,23,.6);overflow:auto}
header{display:flex;gap:12px;align-items:center;margin-bottom:8px}
header h1{font-size:14px;margin:0}
.tree{font-size:13px}
.node{padding:6px;border-radius:6px;cursor:pointer;color:var(--muted)}
.node:hover{background:#0f1a2b;color:#fff}
.node.active{background:linear-gradient(90deg,var(--accent),#2dd4bf);color:#021124}
.toolbar{display:flex;gap:6px;margin-bottom:8px}
button{background:#0b1226;border:1px solid rgba(255,255,255,.04);color:#dbeafe;padding:6px 8px;border-radius:6px;cursor:pointer}
textarea{width:100%;height:60vh;background:#071226;color:#e6eef6;border:1px solid rgba(255,255,255,.04);padding:8px;border-radius:6px;font-family:monospace;font-size:13px}
.preview{background:#071226;padding:10px;border-radius:6px;height:60vh;overflow:auto}
.meta{display:flex;gap:8px;align-items:center;margin-bottom:8px}
input[type=text]{padding:6px;border-radius:6px;border:1px solid rgba(255,255,255,.04);background:#071226;color:#e6eef6}
.small{font-size:12px;color:var(--muted)}
footer{margin-top:8px;font-size:12px;color:var(--muted)}
</style>
</head>
<body>
<div class="app">
<div class="panel" id="left">
<header>
<h1>Repo</h1>
<div class="small" id="status">init...</div>
</header>
<div class="toolbar">
<button id="newFile">New file</button>
<button id="newFolder">New folder</button>
<button id="refreshTree">Refresh</button>
</div>
<div class="tree" id="tree"></div>
<footer class="small">Files stored in browser IndexedDB via LightningFS</footer>
</div>
<div class="panel" id="center">
<div class="meta">
<input id="filepath" type="text" placeholder="objects/example.md" style="flex:1" />
<input id="commitMsg" type="text" placeholder="Commit message" style="width:260px" />
<button id="saveCommit">Save & Commit</button>
</div>
<textarea id="editor" placeholder="File content (Markdown or YAML/JSON)"></textarea>
<div style="display:flex;gap:8px;margin-top:8px">
<button id="initRepo">Init Repo</button>
<button id="logBtn">Log</button>
<button id="setRemote">Set Remote</button>
<button id="pushBtn">Push</button>
<button id="pullBtn">Pull</button>
</div>
<pre id="log" class="small" style="margin-top:8px;white-space:pre-wrap"></pre>
</div>
<div class="panel" id="right">
<header><h1>Markdown Preview</h1></header>
<div class="preview" id="preview">Nothing selected</div>
</div>
</div>
<script type="module">
import LightningFS from 'https://unpkg.com/@isomorphic-git/lightning-fs@4.0.1?module';
import * as git from 'https://unpkg.com/isomorphic-git@1.20.0?module';
import { marked } from 'https://cdn.jsdelivr.net/npm/marked@5.1.1/+esm';
// Basic globals
const repoDir = '/repo';
const fs = new LightningFS('browser-fs', { wipe: false });
git.plugins.set('fs', fs);
const pfs = fs.promises;
const treeEl = document.getElementById('tree');
const editor = document.getElementById('editor');
const preview = document.getElementById('preview');
const filepathInput = document.getElementById('filepath');
const commitMsgInput = document.getElementById('commitMsg');
const statusEl = document.getElementById('status');
const logEl = document.getElementById('log');
// Utility: ensure repo dir exists
async function ensureRepoDir() {
try { await pfs.stat(repoDir); } catch (e) { await pfs.mkdir(repoDir); }
}
// Initialize git repo if not present
async function initRepo() {
status('initializing repo...');
await ensureRepoDir();
try {
const isRepo = await git.isRepo({ fs, dir: repoDir });
if (!isRepo) {
await git.init({ fs, dir: repoDir, defaultBranch: 'main' });
status('repo initialized');
} else {
status('repo already initialized');
}
} catch (e) {
console.error(e);
status('init error: ' + e.message);
}
await refreshTree();
}
// Read directory recursively and build tree
async function readTree(dir = repoDir) {
const items = [];
try {
const names = await pfs.readdir(dir);
for (const name of names) {
const path = dir + '/' + name;
const st = await pfs.stat(path);
if (st && st.type === 'directory') {
items.push({ name, path, type: 'dir', children: await readTree(path) });
} else {
items.push({ name, path, type: 'file' });
}
}
} catch (e) {
// ignore
}
return items;
}
function renderTree(nodes, container) {
container.innerHTML = '';
for (const node of nodes) {
const div = document.createElement('div');
div.className = 'node';
div.textContent = node.name;
div.title = node.path;
div.onclick = async (ev) => {
ev.stopPropagation();
document.querySelectorAll('.node').forEach(n=>n.classList.remove('active'));
div.classList.add('active');
if (node.type === 'file') {
const rel = node.path.replace(repoDir + '/', '');
filepathInput.value = rel;
const data = await pfs.readFile(node.path, { encoding: 'utf8' }).catch(()=>'');
editor.value = data;
preview.innerHTML = marked.parse(data || '');
} else {
// toggle expand: show children inline
if (div.nextSibling && div.nextSibling.classList && div.nextSibling.classList.contains('children')) {
div.nextSibling.remove();
} else {
const childWrap = document.createElement('div');
childWrap.className = 'children';
childWrap.style.paddingLeft = '12px';
renderTree(node.children, childWrap);
div.after(childWrap);
}
}
};
container.appendChild(div);
}
}
async function refreshTree() {
const nodes = await readTree();
renderTree(nodes, treeEl);
status('tree refreshed');
}
// Save file to fs and stage/commit
async function saveAndCommit() {
const rel = filepathInput.value.trim();
if (!rel) { alert('Enter a filepath'); return; }
const full = repoDir + '/' + rel;
// ensure parent dirs
const parts = full.split('/');
for (let i = 2; i <= parts.length - 1; i++) {
const p = parts.slice(0, i).join('/');
try { await pfs.stat(p); } catch (e) { await pfs.mkdir(p); }
}
await pfs.writeFile(full, editor.value, { encoding: 'utf8' });
await git.add({ fs, dir: repoDir, filepath: rel });
const message = commitMsgInput.value.trim() || 'Edit ' + rel;
try {
const sha = await git.commit({
fs, dir: repoDir,
author: { name: 'Browser User', email: 'user@local' },
message
});
status('committed ' + sha.slice(0,7));
await refreshTree();
log('Committed: ' + sha);
} catch (e) {
console.error(e);
status('commit error: ' + e.message);
}
}
// Show log
async function showLog() {
try {
const commits = await git.log({ fs, dir: repoDir, depth: 20 });
logEl.textContent = commits.map(c => `${c.oid.slice(0,7)} ${c.commit.message} — ${c.commit.author.name}`).join('\n\n');
} catch (e) {
logEl.textContent = 'Log error: ' + e.message;
}
}
// Set remote (store in localStorage)
function setRemote() {
const url = prompt('Remote URL (HTTPS, e.g. https://github.com/you/repo.git)');
if (url) {
localStorage.setItem('remoteUrl', url);
status('remote set');
}
}
// Push (prompts for token)
async function push() {
const remote = localStorage.getItem('remoteUrl');
if (!remote) { alert('Set remote first'); return; }
const token = prompt('Personal access token (will not be stored). For GitHub use token as password and username "x-access-token" or "token".');
if (!token) return;
status('pushing...');
try {
await git.addRemote({ fs, dir: repoDir, remote: 'origin', url: remote }).catch(()=>{});
await git.push({
fs, http: git.http, dir: repoDir,
remote: 'origin', ref: 'main',
onAuth: () => ({ username: 'x-access-token', password: token })
});
status('push complete');
} catch (e) {
console.error(e);
status('push failed: ' + e.message);
}
}
// Pull (prompts for token)
async function pull() {
const remote = localStorage.getItem('remoteUrl');
if (!remote) { alert('Set remote first'); return; }
const token = prompt('Personal access token (will not be stored).');
if (!token) return;
status('pulling...');
try {
await git.addRemote({ fs, dir: repoDir, remote: 'origin', url: remote }).catch(()=>{});
await git.pull({
fs, http: git.http, dir: repoDir,
remote: 'origin', ref: 'main',
singleBranch: true,
author: { name: 'Browser User', email: 'user@local' },
onAuth: () => ({ username: 'x-access-token', password: token })
});
status('pull complete');
await refreshTree();
} catch (e) {
console.error(e);
status('pull failed: ' + e.message);
}
}
// Helpers
function status(text) { statusEl.textContent = text; }
function log(text) { logEl.textContent = (new Date()).toLocaleTimeString() + ' — ' + text + '\n' + logEl.textContent; }
// Wire UI
document.getElementById('initRepo').onclick = initRepo;
document.getElementById('refreshTree').onclick = refreshTree;
document.getElementById('saveCommit').onclick = saveAndCommit;
document.getElementById('logBtn').onclick = showLog;
document.getElementById('newFile').onclick = async () => {
const name = prompt('New file path (e.g. objects/note.md)');
if (!name) return;
filepathInput.value = name;
editor.value = '# ' + name.split('/').pop();
};
document.getElementById('newFolder').onclick = async () => {
const name = prompt('New folder path (e.g. objects/notes)');
if (!name) return;
const full = repoDir + '/' + name;
try { await pfs.mkdir(full, { recursive: true }); status('folder created'); await refreshTree(); } catch(e){status('mkdir error')}
};
document.getElementById('setRemote').onclick = setRemote;
document.getElementById('pushBtn').onclick = push;
document.getElementById('pullBtn').onclick = pull;
// Live preview
editor.addEventListener('input', () => {
preview.innerHTML = marked.parse(editor.value || '');
});
// Register service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').then(()=>console.log('sw registered')).catch(()=>console.warn('sw failed'));
}
// Auto init on load
await initRepo();
await refreshTree();
status('ready');
</script>
</body>
</html>