feat: Аккаунт › Вид - tab icons left, right or hidden, terminal font, compact file lists, the time column, animations; kept per user on the server. Uploads show a ring filling as they go
This commit is contained in:
1 parent
c003b51b4c
commit
5216aed237
10 files changed
+167
-3
No files matched your search
@@ -52,6 +52,9 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, name string,
|
|||||||
func (s *Server) shell(w http.ResponseWriter, r *http.Request, data map[string]any) {
|
func (s *Server) shell(w http.ResponseWriter, r *http.Request, data map[string]any) {
|
||||||
data["IsAdmin"] = s.currentAdmin(r).IsAdmin()
|
data["IsAdmin"] = s.currentAdmin(r).IsAdmin()
|
||||||
data["EditLimit"] = proto.FSReadCap
|
data["EditLimit"] = proto.FSReadCap
|
||||||
|
if a := s.currentAdmin(r); a != nil {
|
||||||
|
data["Prefs"] = s.prefs(a.ID)
|
||||||
|
}
|
||||||
if data["View"] == "dashboard" && data["Body"] == nil {
|
if data["View"] == "dashboard" && data["Body"] == nil {
|
||||||
// The machines list under a dialog: draw it too.
|
// The machines list under a dialog: draw it too.
|
||||||
var body bytes.Buffer
|
var body bytes.Buffer
|
||||||
|
|||||||
@@ -115,3 +115,29 @@ func TestSettingsShowAI(t *testing.T) {
|
|||||||
t.Fatal("the AI card is empty")
|
t.Fatal("the AI card is empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The look of the panel is each user's: kept on the server, only known
|
||||||
|
// settings with known values, and given to the page at once.
|
||||||
|
func TestViewPrefs(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
post := func(body string) string {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/panel/account/prefs", strings.NewReader(body))
|
||||||
|
req.AddCookie(c)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(w, req)
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
if got := post(`{"tabIcons":"right","termFont":16,"compact":true,"motion":"off","evil":"<x>","fileTime":"maybe"}`); !strings.Contains(got, `"ok":true`) {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
got := get(t, s, c, "/api/panel/account/prefs").Body.String()
|
||||||
|
if !strings.Contains(got, `"tabIcons":"right"`) || !strings.Contains(got, `"termFont":16`) || strings.Contains(got, "evil") || strings.Contains(got, "maybe") {
|
||||||
|
t.Fatalf("prefs: %s", got)
|
||||||
|
}
|
||||||
|
if body := get(t, s, c, "/workspace").Body.String(); !strings.Contains(body, `"tabIcons":"right"`) {
|
||||||
|
t.Fatal("the page does not get the prefs")
|
||||||
|
}
|
||||||
|
if got := post(`{"termFont":99}`); !strings.Contains(got, `"ok":true`) || strings.Contains(get(t, s, c, "/api/panel/account/prefs").Body.String(), "99") {
|
||||||
|
t.Fatal("a font size out of range was kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Each user's look of the panel (Аккаунт › Вид), kept on the server so it
|
||||||
|
// is the same on every device: where a tab's icons go, the terminal's font
|
||||||
|
// size, a compact file list, the time column, animations.
|
||||||
|
type viewPrefs struct {
|
||||||
|
TabIcons string `json:"tabIcons,omitempty"` // left (default), right, hide
|
||||||
|
TermFont int `json:"termFont,omitempty"` // 11-20; 0: the default
|
||||||
|
Compact bool `json:"compact,omitempty"` // denser file lists
|
||||||
|
FileTime string `json:"fileTime,omitempty"` // show (default), hide
|
||||||
|
Motion string `json:"motion,omitempty"` // on (default), off
|
||||||
|
}
|
||||||
|
|
||||||
|
func prefsKey(id int64) string { return "prefs_" + strconv.FormatInt(id, 10) }
|
||||||
|
|
||||||
|
// clean keeps known values only.
|
||||||
|
func (p viewPrefs) clean() viewPrefs {
|
||||||
|
if p.TabIcons != "right" && p.TabIcons != "hide" {
|
||||||
|
p.TabIcons = ""
|
||||||
|
}
|
||||||
|
if p.TermFont < 11 || p.TermFont > 20 {
|
||||||
|
p.TermFont = 0
|
||||||
|
}
|
||||||
|
if p.FileTime != "hide" {
|
||||||
|
p.FileTime = ""
|
||||||
|
}
|
||||||
|
if p.Motion != "off" {
|
||||||
|
p.Motion = ""
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) prefs(id int64) viewPrefs {
|
||||||
|
var p viewPrefs
|
||||||
|
if v, _ := s.db.GetConfig(prefsKey(id)); v != "" {
|
||||||
|
_ = json.Unmarshal([]byte(v), &p)
|
||||||
|
}
|
||||||
|
return p.clean()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/panel/account/prefs
|
||||||
|
func (s *Server) handlePrefs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, s.prefs(s.currentAdmin(r).ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/account/prefs - the whole set.
|
||||||
|
func (s *Server) handlePrefsSet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var p viewPrefs
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&p); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(p.clean())
|
||||||
|
_ = s.db.SetConfig(prefsKey(s.currentAdmin(r).ID), string(b))
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
@@ -251,6 +251,8 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit))
|
mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit))
|
||||||
mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys))
|
mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys))
|
||||||
mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount))
|
mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount))
|
||||||
|
mux.Handle("GET /api/panel/account/prefs", s.requireAdmin(s.handlePrefs))
|
||||||
|
mux.Handle("POST /api/panel/account/prefs", s.requireAdmin(s.handlePrefsSet))
|
||||||
mux.Handle("GET /api/panel/ai", s.requireAdmin(s.handleAIState))
|
mux.Handle("GET /api/panel/ai", s.requireAdmin(s.handleAIState))
|
||||||
mux.Handle("GET /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHosts))
|
mux.Handle("GET /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHosts))
|
||||||
mux.Handle("POST /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHostAdd))
|
mux.Handle("POST /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHostAdd))
|
||||||
|
|||||||
@@ -56,6 +56,13 @@
|
|||||||
' <option value="0">каждый раз</option><option value="5">5 минут</option><option value="15">15 минут</option>' +
|
' <option value="0">каждый раз</option><option value="5">5 минут</option><option value="15">15 минут</option>' +
|
||||||
' <option value="30">30 минут</option><option value="60">60 минут</option></select></label>' +
|
' <option value="30">30 минут</option><option value="60">60 минут</option></select></label>' +
|
||||||
' <p class="acct-pinmsg" aria-live="polite"></p><p class="muted" style="font-size:12px;margin:8px 0 0">Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.</p></details>' +
|
' <p class="acct-pinmsg" aria-live="polite"></p><p class="muted" style="font-size:12px;margin:8px 0 0">Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.</p></details>' +
|
||||||
|
' <details class="acct-view"><summary>Вид</summary><div class="acct-viewin">' +
|
||||||
|
' <label><span>Значки вкладок</span><select data-pref="tabIcons"><option value="">слева от названия</option><option value="right">справа от названия</option><option value="hide">не показывать</option></select></label>' +
|
||||||
|
' <label><span>Шрифт терминала</span><select data-pref="termFont" data-num><option value="0">обычный</option><option value="12">12</option><option value="13">13</option><option value="14">14</option><option value="15">15</option><option value="16">16</option><option value="18">18</option><option value="20">20</option></select></label>' +
|
||||||
|
' <label><span>Списки файлов</span><select data-pref="compact" data-bool><option value="">обычные</option><option value="1">компактные</option></select></label>' +
|
||||||
|
' <label><span>Время изменения файлов</span><select data-pref="fileTime"><option value="">показывать</option><option value="hide">скрыть</option></select></label>' +
|
||||||
|
' <label><span>Анимации</span><select data-pref="motion"><option value="">включены</option><option value="off">выключены</option></select></label>' +
|
||||||
|
' </div></details>' +
|
||||||
' <div class="acct-keysec">' +
|
' <div class="acct-keysec">' +
|
||||||
' <h3>Ключи доступа</h3>' +
|
' <h3>Ключи доступа</h3>' +
|
||||||
' <p class="muted acct-hint">Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Ключей может быть несколько - например, телефон и ноутбук.</p>' +
|
' <p class="muted acct-hint">Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Ключей может быть несколько - например, телефон и ноутбук.</p>' +
|
||||||
@@ -69,6 +76,15 @@
|
|||||||
dlg.querySelector('.acct-out [name=csrf]').value = decodeURIComponent(csrf);
|
dlg.querySelector('.acct-out [name=csrf]').value = decodeURIComponent(csrf);
|
||||||
var $ = function (sel) { return dlg.querySelector(sel); };
|
var $ = function (sel) { return dlg.querySelector(sel); };
|
||||||
$('[data-close]').onclick = function () { dlg.close(); };
|
$('[data-close]').onclick = function () { dlg.close(); };
|
||||||
|
// Вид: each change applies at once and is kept on the server.
|
||||||
|
dlg.querySelectorAll('[data-pref]').forEach(function (sel) {
|
||||||
|
var k = sel.dataset.pref, cur = window.Prefs ? Prefs.get()[k] : '';
|
||||||
|
sel.value = sel.hasAttribute('data-bool') ? (cur ? '1' : '') : String(cur || (sel.hasAttribute('data-num') ? 0 : ''));
|
||||||
|
sel.onchange = function () {
|
||||||
|
var v = sel.hasAttribute('data-bool') ? sel.value === '1' : sel.hasAttribute('data-num') ? Number(sel.value) : sel.value;
|
||||||
|
if (window.Prefs) Prefs.set(k, v).then(function (ok) { if (!ok) msg('не сохранилось', true); });
|
||||||
|
};
|
||||||
|
});
|
||||||
var supported = !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext);
|
var supported = !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext);
|
||||||
|
|
||||||
function msg(text, bad) {
|
function msg(text, bad) {
|
||||||
|
|||||||
@@ -1003,3 +1003,25 @@ details[open]::details-content { block-size: auto; opacity: 1; }
|
|||||||
.ai-act:hover:not(:disabled) { filter:brightness(1.1); }
|
.ai-act:hover:not(:disabled) { filter:brightness(1.1); }
|
||||||
.ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; }
|
.ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; }
|
||||||
.ai-code.ai-action pre { margin:0; border:0; border-radius:0; background:transparent; }
|
.ai-code.ai-action pre { margin:0; border:0; border-radius:0; background:transparent; }
|
||||||
|
/* Аккаунт › Вид. */
|
||||||
|
.acct-view { margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
|
||||||
|
.acct-view summary { font-size:14px; color:var(--text); }
|
||||||
|
.acct-viewin { display:grid; gap:8px; margin-top:10px; }
|
||||||
|
.acct-viewin label { display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:13px; color:var(--muted); }
|
||||||
|
.acct-viewin select { width:auto; flex:none; min-width:170px; }
|
||||||
|
/* The look chosen. */
|
||||||
|
body[data-tabicons="right"] .tab-kinds { order:2; }
|
||||||
|
body[data-tabicons="right"] .tab-title { order:1; }
|
||||||
|
body[data-tabicons="right"] .tab-x { order:3; }
|
||||||
|
body[data-tabicons="hide"] .tab-kinds { display:none; }
|
||||||
|
body.compact table.fm td { padding-top:3px; padding-bottom:3px; }
|
||||||
|
body.compact table.fm td .btn.icon { width:28px; height:28px; }
|
||||||
|
body.no-ftime table.fm .c-mt, body.no-ftime table.fm td.mt { display:none; }
|
||||||
|
body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-motion::backdrop { transition:none !important; animation:none !important; }
|
||||||
|
/* Uploading: a small ring that fills as the files go up. */
|
||||||
|
.fp-ring { display:none; transform:rotate(-90deg); flex:none; }
|
||||||
|
.busy.uploading .spinner { display:none; }
|
||||||
|
.busy.uploading .fp-ring { display:block; }
|
||||||
|
.fp-ring circle { fill:none; stroke-width:2.6; }
|
||||||
|
.fp-ring .bg { stroke:var(--border); }
|
||||||
|
.fp-ring .fg { stroke:var(--accent); stroke-linecap:round; stroke-dasharray:100; stroke-dashoffset:calc(100 - var(--up, 0)); transition:stroke-dashoffset .25s ease; }
|
||||||
@@ -104,7 +104,9 @@
|
|||||||
' <button class="btn ghost small icon sq fp-up" type="button" title="Вверх" aria-label="Вверх"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>' +
|
' <button class="btn ghost small icon sq fp-up" type="button" title="Вверх" aria-label="Вверх"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>' +
|
||||||
' <div class="fm-pathbox"><input class="fm-path fp-path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Папка">' +
|
' <div class="fm-pathbox"><input class="fm-path fp-path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Папка">' +
|
||||||
' <button type="button" class="btn ghost small icon sq fp-dirprops" title="Свойства папки" aria-label="Свойства папки"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button></div>' +
|
' <button type="button" class="btn ghost small icon sq fp-dirprops" title="Свойства папки" aria-label="Свойства папки"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button></div>' +
|
||||||
' <button type="button" class="busy fp-busy" title=""><span class="spinner"></span><span class="fp-uppct"></span></button>' +
|
' <button type="button" class="busy fp-busy" title=""><span class="spinner"></span>' +
|
||||||
|
'<svg class="fp-ring" width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><circle class="bg" cx="10" cy="10" r="8"/><circle class="fg" cx="10" cy="10" r="8" pathLength="100"/></svg>' +
|
||||||
|
'<span class="fp-uppct"></span></button>' +
|
||||||
' <button class="btn ghost small icon sq fp-refresh" type="button" title="Обновить" aria-label="Обновить"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></button>' +
|
' <button class="btn ghost small icon sq fp-refresh" type="button" title="Обновить" aria-label="Обновить"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="fm-bar">' +
|
'<div class="fm-bar">' +
|
||||||
@@ -378,6 +380,9 @@
|
|||||||
busy.classList.toggle('up', ups.length > 0);
|
busy.classList.toggle('up', ups.length > 0);
|
||||||
busy.title = ups.length ? 'Показать загружаемые файлы' : '';
|
busy.title = ups.length ? 'Показать загружаемые файлы' : '';
|
||||||
if (pend.length) pct.textContent = Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '');
|
if (pend.length) pct.textContent = Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '');
|
||||||
|
// While files go up, the round mark fills as they do.
|
||||||
|
busy.classList.toggle('uploading', pend.length > 0);
|
||||||
|
busy.style.setProperty('--up', String(pend.length ? 100 * sent / total : 0));
|
||||||
pct.classList.toggle('on', pend.length > 0);
|
pct.classList.toggle('on', pend.length > 0);
|
||||||
FV.uploadsChanged(this);
|
FV.uploadsChanged(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// The look of the panel, as each user set it (Аккаунт › Вид): applied at
|
||||||
|
// once on load from what the page came with, and again on every change.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
let p = Object.assign({}, (window.APP && window.APP.prefs) || {});
|
||||||
|
function apply() {
|
||||||
|
const b = document.body;
|
||||||
|
b.dataset.tabicons = p.tabIcons || 'left';
|
||||||
|
b.classList.toggle('compact', !!p.compact);
|
||||||
|
b.classList.toggle('no-ftime', p.fileTime === 'hide');
|
||||||
|
b.classList.toggle('no-motion', p.motion === 'off');
|
||||||
|
// Open terminals take the new size at once.
|
||||||
|
if (window.Workspace) Workspace.panes().forEach(t => {
|
||||||
|
if (t.term && t.term.options.fontSize !== termFont()) { t.term.options.fontSize = termFont(); t.fit(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const termFont = () => p.termFont || (matchMedia('(pointer: coarse)').matches ? 13 : 14);
|
||||||
|
async function set(k, v) {
|
||||||
|
p[k] = v;
|
||||||
|
apply();
|
||||||
|
const r = await fetch('/api/panel/account/prefs', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
credentials: 'same-origin', body: JSON.stringify(p)}).then(x => x.json()).catch(() => ({ok: false}));
|
||||||
|
return r.ok;
|
||||||
|
}
|
||||||
|
apply();
|
||||||
|
window.Prefs = {get: () => Object.assign({}, p), set, apply, termFont};
|
||||||
|
})();
|
||||||
@@ -283,7 +283,7 @@ window.TermKeys = (function () {
|
|||||||
this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name;
|
this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name;
|
||||||
if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
||||||
this.term = new Terminal({
|
this.term = new Terminal({
|
||||||
cursorBlink: true, fontSize: touch ? 13 : 14,
|
cursorBlink: true, fontSize: window.Prefs ? Prefs.termFont() : (touch ? 13 : 14),
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' }
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' }
|
||||||
});
|
});
|
||||||
this.fitter = new FitAddon.FitAddon();
|
this.fitter = new FitAddon.FitAddon();
|
||||||
|
|||||||
@@ -193,9 +193,10 @@
|
|||||||
<button type="button" class="btn ghost small icon close x md-close" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
|
<button type="button" class="btn ghost small icon close x md-close" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
|
||||||
<div class="frag-area"></div></dialog>
|
<div class="frag-area"></div></dialog>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script>window.APP = {admin: {{.IsAdmin}}, open: {{.Open}}, dialog: {{.Dialog}}, editLimit: {{.EditLimit}}};</script>
|
<script>window.APP = {admin: {{.IsAdmin}}, open: {{.Open}}, dialog: {{.Dialog}}, editLimit: {{.EditLimit}}, prefs: {{.Prefs}}};</script>
|
||||||
<script src="{{asset "wsutil.js"}}"></script>
|
<script src="{{asset "wsutil.js"}}"></script>
|
||||||
<script src="{{asset "ui.js"}}"></script>
|
<script src="{{asset "ui.js"}}"></script>
|
||||||
|
<script src="{{asset "prefs.js"}}"></script>
|
||||||
<script src="{{asset "xterm.js"}}"></script>
|
<script src="{{asset "xterm.js"}}"></script>
|
||||||
<script src="{{asset "addon-fit.js"}}"></script>
|
<script src="{{asset "addon-fit.js"}}"></script>
|
||||||
<script src="{{asset "app.js"}}"></script>
|
<script src="{{asset "app.js"}}"></script>
|
||||||
|
|||||||
Reference in new issue
Block a user