Paillasse/design/js/paillasse-terminal.js

200 lines
12 KiB
JavaScript
Raw Normal View History

// Terminal élève Syllabus : xterm.js + un petit shell simulé (vitrine et lab d'essai).
// En production, le terminal se branche sur le proxy WebSocket de la console série Nova.
(function () {
var THEME = {
background: '#13233A', foreground: '#E8EDF5', cursor: '#FFD23F', cursorAccent: '#13233A',
selectionBackground: '#2B4A73', black: '#0D192B', red: '#FF7A66', green: '#7CF2C0', yellow: '#FFD23F',
blue: '#8DB4FF', magenta: '#FF6FB5', cyan: '#7CE3F2', white: '#E8EDF5',
brightBlack: '#6B7F99', brightRed: '#FF9A8A', brightGreen: '#7CF2C0', brightYellow: '#FFD23F',
brightBlue: '#A9C6FF', brightMagenta: '#FF8FC6', brightCyan: '#9CEBF7', brightWhite: '#FFFFFF'
};
var G = '\x1b[1;92m', B = '\x1b[1;94m', Y = '\x1b[93m', R = '\x1b[91m', D = '\x1b[2m', X = '\x1b[0m';
function Shell(term, o) {
this.t = term; this.o = o;
this.user = o.user || 'eleve'; this.host = o.host || 'debian-lab';
this.home = '/home/' + this.user; this.cwd = this.home;
this.dirs = new Set(['/', '/home', this.home, '/etc', '/tmp', '/var', '/var/log']);
this.files = new Map([
[this.home + '/.bashrc', '# ~/.bashrc'],
['/etc/hostname', this.host],
['/etc/os-release', 'PRETTY_NAME="Debian GNU/Linux 13 (trixie)"\nID=debian']
]);
this.line = ''; this.hist = []; this.hi = 0; this.busy = false;
}
Shell.prototype.short = function (p) { return p === this.home ? '~' : p.indexOf(this.home + '/') === 0 ? '~' + p.slice(this.home.length) : p; };
Shell.prototype.prompt = function () { this.t.write(G + this.user + '@' + this.host + X + ':' + B + this.short(this.cwd) + X + '$ '); };
Shell.prototype.path = function (p) {
if (!p || p === '~') return this.home;
if (p.indexOf('~/') === 0) p = this.home + p.slice(1);
var parts = (p[0] === '/' ? p : this.cwd + '/' + p).split('/'), out = [];
parts.forEach(function (s) { if (!s || s === '.') return; if (s === '..') out.pop(); else out.push(s); });
return '/' + out.join('/');
};
Shell.prototype.parent = function (p) { var i = p.lastIndexOf('/'); return i <= 0 ? '/' : p.slice(0, i); };
Shell.prototype.base = function (p) { return p.slice(p.lastIndexOf('/') + 1); };
Shell.prototype.children = function (d) {
var self = this, out = [];
this.dirs.forEach(function (x) { if (x !== '/' && self.parent(x) === d) out.push({ n: self.base(x), dir: true }); });
this.files.forEach(function (v, x) { if (self.parent(x) === d) out.push({ n: self.base(x), dir: false, size: v.length }); });
return out.sort(function (a, b) { return a.n.replace(/^\./, '').localeCompare(b.n.replace(/^\./, '')); });
};
Shell.prototype.out = function (s) { this.t.write(String(s).replace(/\n/g, '\r\n') + '\r\n'); };
Shell.prototype.err = function (s) { this.out(R + s + X); };
Shell.prototype.exec = function (raw) {
var self = this, parts = raw.split(/&&|;/);
for (var i = 0; i < parts.length; i++) { if (!self.run(parts[i].trim())) break; }
if (this.o.onCommand) this.o.onCommand(raw, this);
};
Shell.prototype.run = function (cmd) {
if (!cmd) return true;
var toks = [], m, re = /"([^"]*)"|'([^']*)'|(\S+)/g;
while ((m = re.exec(cmd))) toks.push(m[1] != null ? m[1] : m[2] != null ? m[2] : m[3]);
var redir = null, ri = toks.findIndex(function (t) { return t === '>' || t === '>>'; });
if (ri > -1) { redir = { mode: toks[ri], file: toks[ri + 1] }; toks = toks.slice(0, ri); }
var c = toks[0], a = toks.slice(1), self = this, flags = a.filter(function (x) { return x[0] === '-'; }).join(''), args = a.filter(function (x) { return x[0] !== '-'; });
var capture = null, print = function (s) { if (redir) capture = (capture ? capture + '\n' : '') + s; else self.out(s); };
switch (c) {
case 'help': print('Commandes : pwd, ls, cd, mkdir, touch, echo, cat, rm, rmdir, whoami, hostname, date, uname, ip a, clear'); break;
case 'pwd': print(this.cwd); break;
case 'whoami': print(this.user); break;
case 'hostname': print(this.host); break;
case 'date': print(new Date().toLocaleString('fr-FR', { dateStyle: 'full', timeStyle: 'medium' })); break;
case 'uname': print(flags.indexOf('a') > -1 ? 'Linux ' + this.host + ' 6.12.0-amd64 #1 SMP Debian x86_64 GNU/Linux' : 'Linux'); break;
case 'clear': this.t.clear(); break;
case 'ip':
if (/^a|^addr/.test(args[0] || '')) print('1: lo: <LOOPBACK,UP> mtu 65536\n inet 127.0.0.1/8 scope host lo\n2: ens3: <BROADCAST,MULTICAST,UP> mtu 1500\n inet 192.168.10.10/26 brd 192.168.10.63 scope global ens3');
else print('Usage : ip addr'); break;
case 'ls': {
var d = this.path(args[0] || '.');
if (!this.dirs.has(d)) { this.err("ls: impossible d'accéder à '" + (args[0] || '.') + "': Aucun fichier ou dossier de ce nom"); return false; }
var all = flags.indexOf('a') > -1, long = flags.indexOf('l') > -1;
var list = this.children(d).filter(function (e) { return all || e.n[0] !== '.'; });
if (all) list = [{ n: '.', dir: true }, { n: '..', dir: true }].concat(list);
var col = function (e) { return redir ? e.n : e.dir ? B + e.n + X : e.n; };
if (long) {
var now = new Date(), stamp = now.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' }) + ' ' + now.toTimeString().slice(0, 5);
print('total ' + list.length * 4);
list.forEach(function (e) { print((e.dir ? 'drwxr-xr-x 2 ' : '-rw-r--r-- 1 ') + self.user + ' ' + self.user + ' ' + String(e.dir ? 4096 : e.size).padStart(5) + ' ' + stamp + ' ' + col(e)); });
} else if (list.length) print(list.map(col).join(' '));
break;
}
case 'cd': {
var p = this.path(args[0]);
if (!this.dirs.has(p)) { this.err('bash: cd: ' + args[0] + ': Aucun fichier ou dossier de ce nom'); return false; }
this.cwd = p; break;
}
case 'mkdir':
if (!args.length) { this.err('mkdir: opérande manquant'); return false; }
for (var i = 0; i < args.length; i++) {
var np = this.path(args[i]);
if (this.dirs.has(np)) { if (flags.indexOf('p') < 0) { this.err("mkdir: impossible de créer le répertoire « " + args[i] + " »: Le fichier existe"); return false; } continue; }
if (!this.dirs.has(this.parent(np))) {
if (flags.indexOf('p') < 0) { this.err("mkdir: impossible de créer le répertoire « " + args[i] + " »: Aucun fichier ou dossier de ce nom"); return false; }
var acc = ''; np.split('/').slice(1).forEach(function (s) { acc += '/' + s; self.dirs.add(acc); });
} else this.dirs.add(np);
}
break;
case 'touch':
if (!args.length) { this.err('touch: opérande de fichier manquant'); return false; }
args.forEach(function (f) { var fp = self.path(f); if (!self.files.has(fp)) self.files.set(fp, ''); }); break;
case 'echo': print(args.join(' ')); break;
case 'cat': {
var f = this.path(args[0]);
if (this.dirs.has(f)) { this.err('cat: ' + args[0] + ': est un dossier'); return false; }
if (!this.files.has(f)) { this.err('cat: ' + args[0] + ': Aucun fichier ou dossier de ce nom'); return false; }
var v = this.files.get(f); if (v) print(v); break;
}
case 'rm':
for (var k = 0; k < args.length; k++) {
var rp = this.path(args[k]);
if (this.dirs.has(rp)) { this.err("rm: impossible de supprimer '" + args[k] + "': est un dossier"); return false; }
if (!this.files.delete(rp)) { this.err("rm: impossible de supprimer '" + args[k] + "': Aucun fichier ou dossier de ce nom"); return false; }
}
break;
case 'rmdir': {
var dp = this.path(args[0]);
if (!this.dirs.has(dp)) { this.err("rmdir: '" + args[0] + "': Aucun fichier ou dossier de ce nom"); return false; }
if (this.children(dp).length) { this.err("rmdir: '" + args[0] + "': Le dossier n'est pas vide"); return false; }
this.dirs.delete(dp); break;
}
case 'sudo': if (this.o.commands) return this.run(a.join(' ')); print(Y + 'Pas besoin de sudo pour ce lab : tout se passe dans ton dossier.' + X); break;
default:
if (this.o.commands && this.o.commands[c]) { var res = this.o.commands[c](a, this); if (res === false) return false; if (res) print(res); break; }
this.err('bash: ' + c + ' : commande introuvable'); return false;
}
if (redir) {
if (!redir.file) { this.err('bash: erreur de syntaxe près du symbole inattendu « newline »'); return false; }
var fp2 = this.path(redir.file);
if (!this.dirs.has(this.parent(fp2))) { this.err('bash: ' + redir.file + ': Aucun fichier ou dossier de ce nom'); return false; }
var prev = redir.mode === '>>' && this.files.has(fp2) ? this.files.get(fp2) : '';
this.files.set(fp2, prev ? prev + '\n' + (capture || '') : (capture || ''));
}
return true;
};
Shell.prototype.key = function (d) {
if (this.busy) return;
var t = this.t;
if (d === '\r') { t.write('\r\n'); var l = this.line; this.line = ''; if (l.trim()) { this.hist.push(l); } this.hi = this.hist.length; this.exec(l); this.prompt(); return; }
if (d === '\x7f') { if (this.line) { this.line = this.line.slice(0, -1); t.write('\b \b'); } return; }
if (d === '\x03') { t.write('^C\r\n'); this.line = ''; this.prompt(); return; }
if (d === '\x0c') { t.clear(); return; }
if (d === '\x1b[A' || d === '\x1b[B') {
if (!this.hist.length) return;
this.hi = Math.max(0, Math.min(this.hist.length, this.hi + (d === '\x1b[A' ? -1 : 1)));
var h = this.hist[this.hi] || '';
t.write('\b \b'.repeat(this.line.length)); this.line = h; t.write(h); return;
}
if (d[0] === '\x1b') return;
var clean = d.replace(/[\x00-\x1f]/g, ''); this.line += clean; t.write(clean);
};
Shell.prototype.demo = function (cmds, loop) {
var self = this, reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
this.busy = true;
var i = 0;
function next() {
if (i >= cmds.length) {
if (!loop || reduce) return;
return setTimeout(function () { self.t.clear(); self.t.write('\x1b[2J\x1b[H'); self.cwd = self.home; self.dirs = new Set(['/', '/home', self.home, '/etc', '/tmp', '/var', '/var/log']); self.files.forEach(function (v, k) { if (k.indexOf(self.home + '/') === 0 && k !== self.home + '/.bashrc') self.files.delete(k); }); i = 0; self.prompt(); setTimeout(next, 600); }, 3500);
}
var c = cmds[i++], j = 0;
if (reduce) { self.t.write(c + '\r\n'); self.exec(c); self.prompt(); return next(); }
(function type() {
if (j < c.length) { self.t.write(c[j++]); return setTimeout(type, 40 + Math.random() * 50); }
setTimeout(function () { self.t.write('\r\n'); self.exec(c); self.prompt(); setTimeout(next, 900); }, 300);
})();
}
setTimeout(next, 700);
};
function mount(el, o) {
o = o || {};
var term = new window.Terminal({
theme: THEME, fontFamily: '"IBM Plex Mono", ui-monospace, monospace', fontSize: o.fontSize || 14,
lineHeight: 1.25, cursorBlink: true, cursorStyle: 'block', scrollback: 500, convertEol: false,
disableStdin: !!o.demo, allowTransparency: false
});
var fit = window.FitAddon ? new window.FitAddon.FitAddon() : null;
if (fit) term.loadAddon(fit);
term.open(el);
var fitNow = function () { try { fit && fit.fit(); } catch (e) {} };
var raf, doFit = function () { cancelAnimationFrame(raf); raf = requestAnimationFrame(fitNow); };
fitNow(); window.addEventListener('resize', doFit);
if (window.ResizeObserver) new ResizeObserver(doFit).observe(el);
var sh = new Shell(term, o);
if (o.welcome) term.write(o.welcome.replace(/\n/g, '\r\n') + '\r\n');
sh.prompt();
if (o.demo) sh.demo(o.demo, o.loop !== false);
else { term.onData(function (d) { sh.key(d); }); if (o.focus) term.focus(); }
return sh;
}
window.SyllabusTerminal = { mount: function (el, o) {
var go = function () { mount(el, o); };
(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(go);
} };
})();