feat: file manager as a workspace pane, side by side with terminals; zip and delete of the selection as icons
This commit is contained in:
1 parent
012c0af7f9
commit
f5d6bd30e8
8 files changed
+1782
-1689
No files matched your search
@@ -5,6 +5,8 @@ import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// A panel page is either a fragment - its content alone, for the shell to put
|
||||
@@ -49,6 +51,7 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, name string,
|
||||
// shell writes the shell page with data (View, Body, Open, Dialog).
|
||||
func (s *Server) shell(w http.ResponseWriter, r *http.Request, data map[string]any) {
|
||||
data["IsAdmin"] = s.currentAdmin(r).IsAdmin()
|
||||
data["EditLimit"] = proto.FSReadCap
|
||||
if data["View"] == "dashboard" && data["Body"] == nil {
|
||||
// The machines list under a dialog: draw it too.
|
||||
var body bytes.Buffer
|
||||
|
||||
@@ -34,16 +34,6 @@ var fsOps = map[string]string{
|
||||
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
|
||||
const fsTaskTimeout = 60 * time.Second
|
||||
|
||||
func (s *Server) handleFilesPage(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.render(w, "files.html", map[string]any{"Agent": a, "EditLimit": proto.FSReadCap})
|
||||
}
|
||||
|
||||
// POST /api/panel/agents/{id}/fs/{op} - run a file-manager operation on the
|
||||
// agent and return its result. The request body is the op's JSON args and is
|
||||
// forwarded verbatim to the agent.
|
||||
|
||||
@@ -422,3 +422,340 @@ body.touch .keys {
|
||||
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
|
||||
body.touch .area { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
|
||||
#status:empty { display:none; }
|
||||
|
||||
/* ---- File panes and their dialogs ---- */
|
||||
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
|
||||
/* Normalize buttons and the file <label> button so they line up on one row. */
|
||||
.fm-bar .btn, .fm-bar button, .fm-bar label.btn {
|
||||
display:inline-flex; align-items:center; justify-content:center;
|
||||
vertical-align:middle; height:34px; line-height:1; margin:0; box-sizing:border-box;
|
||||
}
|
||||
/* A button with an icon before its text. */
|
||||
.fm-bar .btn.ico { gap:6px; }
|
||||
.fm-bar .spacer { flex:1; }
|
||||
input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted);
|
||||
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
|
||||
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
|
||||
input.fm-path:focus { outline:none; border-color:var(--accent); color:var(--text); }
|
||||
/* The path field, with the folder's properties button inside it on the right. */
|
||||
.fm-pathbox { position:relative; flex:1; min-width:200px; display:flex; }
|
||||
.fm-pathbox input.fm-path { width:100%; min-width:0; padding-right:40px; }
|
||||
.fm-bar .fm-pathbox .btn.sq { position:absolute; right:4px; top:4px; width:26px; height:26px; border:1px solid transparent; }
|
||||
.fm-bar .fm-pathbox .btn.sq:hover { border-color:var(--border); background:#151a21; }
|
||||
.spinner { width:16px; height:16px; border:2px solid var(--border); border-top-color:var(--accent);
|
||||
border-radius:50%; animation:spin .7s linear infinite; display:inline-block; flex:none; }
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
/* Busy indicator; while files upload it also shows how far they got, and a
|
||||
click on it lists them. It takes room only while it shows. */
|
||||
.fp-uppct { display:inline-block; max-width:0; margin-left:0; overflow:hidden; white-space:nowrap;
|
||||
transition:max-width .3s ease, margin-left .3s ease; }
|
||||
.fp-uppct.on { max-width:110px; margin-left:8px; }
|
||||
.busy { flex:none; display:none; align-items:center; gap:8px; background:none; border:0; padding:0 2px;
|
||||
height:34px; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; cursor:default; }
|
||||
/* Folded to nothing when idle (the negative margin takes back the bar's
|
||||
gap), it opens smoothly, and only for work that lasts a moment. */
|
||||
.fm-bar .busy { display:inline-flex; gap:0; box-sizing:border-box; max-width:0; padding:0; margin-left:-8px; opacity:0;
|
||||
overflow:hidden; white-space:nowrap; transition:max-width .3s ease, margin-left .3s ease, opacity .25s ease; }
|
||||
.fm-bar .busy.on { max-width:140px; margin-left:0; opacity:1; transition-delay:.15s; }
|
||||
.busy:hover { background:none; }
|
||||
.busy.up { cursor:pointer; color:var(--text); }
|
||||
.busy.up:hover { color:var(--accent); }
|
||||
#updlg { max-width:480px; }
|
||||
.upl-item { padding:8px 0; border-bottom:1px solid var(--border); }
|
||||
.upl-item:last-child { border-bottom:0; }
|
||||
.upl-row { display:flex; gap:12px; justify-content:space-between; font-size:14px; }
|
||||
.upl-row .n { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.upl-row .st { flex:none; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; }
|
||||
.upl-row .st.ok { color:var(--ok); }
|
||||
.upl-row .st.bad { color:var(--danger); }
|
||||
.upl-bar { height:4px; margin-top:6px; border-radius:2px; background:#0c0f13; overflow:hidden; }
|
||||
.upl-bar i { display:block; height:100%; background:var(--accent); transform-origin:left; transition:transform .2s; }
|
||||
.fp-drop { border:1px dashed var(--border); border-radius:10px; }
|
||||
.fp-drop.drag { border-color:var(--accent); background:#12202f; }
|
||||
table.fm td, table.fm th { padding:7px 8px; }
|
||||
.fm .nm { cursor:pointer; }
|
||||
.fm .nm:hover { color:var(--accent); }
|
||||
.ic { display:inline-flex; vertical-align:-3px; width:18px; justify-content:center; margin-right:6px; color:var(--muted); }
|
||||
.ic.dir { color:#7aa7d9; }
|
||||
.ic.dir svg { fill:rgba(122,167,217,.16); }
|
||||
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
|
||||
.act { display:flex; gap:6px; justify-content:flex-end; }
|
||||
/* Sortable headers: a click sorts by the column, a second one turns it round. */
|
||||
table.fm th.srt { cursor:pointer; user-select:none; white-space:nowrap; }
|
||||
table.fm th.srt:hover { color:var(--text); }
|
||||
table.fm .c-sz { width:84px; }
|
||||
table.fm .c-mt { width:150px; }
|
||||
table.fm .c-act { width:1%; }
|
||||
/* The name takes what is left: size, time and buttons as narrow as they go. */
|
||||
table.fm td.sz { white-space:nowrap; }
|
||||
table.fm .act { flex-wrap:nowrap; }
|
||||
.fp-sel { display:inline-flex; align-items:center; gap:6px; flex:none; margin-left:auto; }
|
||||
.fp-selinfo { font-size:13px; margin-right:2px; }
|
||||
.fp-sel .btn.sq { width:34px; }
|
||||
.fp-selinfo:empty { display:none; }
|
||||
.fm .nm { overflow-wrap:anywhere; }
|
||||
/* Phones: no date column, and the row buttons only as wide as they are. */
|
||||
@media (max-width: 640px) {
|
||||
table.fm .c-mt, table.fm td.mt { display:none; }
|
||||
table.fm .c-sz { width:auto; white-space:nowrap; }
|
||||
table.fm .c-act { width:1%; }
|
||||
table.fm td, table.fm th { padding:7px 4px; }
|
||||
.ic { margin-right:4px; }
|
||||
.fm-bar .fm-pathbox { flex:1 1 100%; order:-1; }
|
||||
/* The selection's buttons go together on a line of their own. */
|
||||
.fm-bar .spacer { flex:1 1 100%; height:0; }
|
||||
}
|
||||
#save { display:inline-flex; align-items:center; gap:8px; }
|
||||
#save.saving { opacity:.85; cursor:progress; }
|
||||
#save.saving::before { content:''; width:12px; height:12px; border:2px solid rgba(255,255,255,.35); border-top-color:#fff;
|
||||
border-radius:50%; animation:spin .7s linear infinite; }
|
||||
#save[hidden] { display:none; }
|
||||
/* Square icon buttons, the height of the buttons beside them. */
|
||||
.btn.sq { width:28px; height:28px; padding:0; display:inline-flex; align-items:center; justify-content:center; }
|
||||
.fm-bar .btn.sq { width:34px; }
|
||||
/* Move or copy: the action, where to with suggestions, the name, and what
|
||||
to do with what is already there. */
|
||||
#mvdlg { max-width:560px; }
|
||||
#mvdlg .dlg-b { padding:14px 18px 16px; }
|
||||
.mv label { margin:12px 0 4px; }
|
||||
.mv .pp-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; overflow-wrap:anywhere; }
|
||||
.mv .pp-act { display:flex; justify-content:flex-end; margin-top:16px; }
|
||||
.mv input[type=text], .mv select { width:100%; height:36px; padding:6px 10px; }
|
||||
.mv select { padding-right:36px; }
|
||||
.seg { display:inline-flex; margin-top:12px; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
||||
.seg button { background:transparent; color:var(--muted); border:0; border-radius:0; padding:7px 16px; font-size:14px; }
|
||||
.seg button + button { border-left:1px solid var(--border); }
|
||||
.seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 -2px 0 var(--accent); }
|
||||
.seg button:hover { background:#151a21; color:var(--text); }
|
||||
.mv-top { display:flex; align-items:flex-end; gap:8px; }
|
||||
.mv-top .mv-net { height:36px; width:36px; flex:none; color:var(--muted); transition:background .18s, color .18s, border-color .18s; }
|
||||
.mv-top .mv-net:hover { color:var(--text); }
|
||||
.mv-top .mv-net.on { color:var(--accent); border-color:var(--accent); background:#12202f; }
|
||||
/* The other machine: slides open under the action. */
|
||||
.mv-remote { display:grid; grid-template-rows:0fr; opacity:0; transition:grid-template-rows .25s ease, opacity .2s ease; }
|
||||
.mv-remote > div { overflow:hidden; min-height:0; }
|
||||
.mv-remote.on { grid-template-rows:1fr; opacity:1; }
|
||||
.mv-remote.on > div { overflow:visible; }
|
||||
.mv-ssh { display:grid; gap:8px; margin-top:10px; padding:10px 12px; border:1px solid var(--border); border-radius:9px; background:#0f1318;
|
||||
animation:mv-in .2s ease; }
|
||||
.mv-ssh[hidden], .mv-a[hidden] { display:none; }
|
||||
@keyframes mv-in { from { opacity:0; transform:translateY(-4px); } }
|
||||
.mv-ssh label { margin:0; display:grid; gap:4px; font-size:12px; color:var(--muted); }
|
||||
.mv-ssh input[type=number] { width:100%; height:36px; padding:6px 10px; }
|
||||
.mv-row { display:flex; gap:10px; align-items:end; }
|
||||
.mv-row .grow { flex:1; min-width:0; }
|
||||
.mv-row .port { width:92px; flex:none; }
|
||||
.mv-ssh .seg-auth { margin-top:2px; display:flex; }
|
||||
.mv-ssh .seg-auth button { flex:1; padding:6px 8px; font-size:13px; }
|
||||
@media (max-width:520px) { .mv-ssh .seg-auth button { font-size:12px; padding:6px 4px; white-space:nowrap; } }
|
||||
.mv-a { display:grid; gap:8px; animation:mv-in .18s ease; }
|
||||
.mv-a textarea { width:100%; box-sizing:border-box; font:12px/1.4 ui-monospace,Menlo,monospace; resize:vertical; min-height:80px;
|
||||
background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||||
.mv-a textarea:focus { border-color:var(--accent); outline:none; }
|
||||
p.mv-a { margin:0; font-size:13px; }
|
||||
#mv-keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; }
|
||||
.mv-prog { margin-top:14px; animation:mv-in .2s ease; }
|
||||
.mv-prog[hidden] { display:none; }
|
||||
.mv-bar { height:6px; border-radius:3px; background:#1a2029; overflow:hidden; }
|
||||
.mv-bar i { display:block; height:100%; width:0; background:var(--accent); border-radius:3px; transition:width .5s ease; }
|
||||
.mv-bar.wait i { width:30% !important; animation:mv-slide 1.1s ease-in-out infinite; }
|
||||
@keyframes mv-slide { from { transform:translateX(-100%); } to { transform:translateX(340%); } }
|
||||
.mv-pwrap { display:inline-flex; align-items:center; gap:8px; min-width:0; }
|
||||
.mv-ptext { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:8px; font-size:13px; color:var(--muted); }
|
||||
.mv.running input, .mv.running select, .mv.running textarea, .mv.running .seg, .mv.running .mv-net { pointer-events:none; opacity:.6; }
|
||||
.mv-dirbox { position:relative; }
|
||||
.mv-dirbox input { font-family:ui-monospace,Menlo,monospace; font-size:13px; }
|
||||
.mv-sug { position:absolute; left:0; right:0; top:calc(100% + 4px); z-index:5; max-height:220px; overflow:auto;
|
||||
margin:0; padding:4px; list-style:none; background:var(--panel); border:1px solid var(--border); border-radius:8px;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.45); font-family:ui-monospace,Menlo,monospace; font-size:13px; }
|
||||
.mv-sug li { padding:5px 8px; border-radius:5px; cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.mv-sug li.sel, .mv-sug li:hover { background:#12202f; color:var(--text); }
|
||||
.mv-sug li .muted { color:var(--muted); }
|
||||
/* Properties: owner, group and mode, like FileZilla's. */
|
||||
#propdlg { max-width:520px; }
|
||||
#propdlg .dlg-b { padding:14px 18px 16px; }
|
||||
.props .pp-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; overflow-wrap:anywhere; }
|
||||
.props .pp-info { font-size:13px; margin:2px 0 12px; }
|
||||
.props .pp-own { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
.props .pp-own label { margin:0; }
|
||||
.props .pp-own input { margin-top:4px; height:36px; padding:6px 10px; }
|
||||
.props table.pp-perm { margin:12px 0 4px; }
|
||||
.props .pp-perm th, .props .pp-perm td { padding:4px 8px; text-align:center; font-size:13px; }
|
||||
.props .pp-perm tbody th { text-align:left; color:var(--text); font-weight:400; }
|
||||
.props .pp-perm input { width:16px; height:16px; cursor:pointer; }
|
||||
.props .pp-num { display:flex; align-items:center; gap:10px; margin:8px 0 0; }
|
||||
.props .pp-num input { width:80px; height:34px; padding:4px 10px; font-family:ui-monospace,Menlo,monospace; }
|
||||
.props .pp-num input.bad { border-color:var(--danger); }
|
||||
.props .pp-rec { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px 16px; margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
|
||||
.props .pp-rec label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
|
||||
.props .pp-rec select { min-width:0; padding:6px 32px 6px 12px; }
|
||||
.props .pp-act { display:flex; justify-content:flex-end; margin-top:14px; }
|
||||
/* File viewer: the code editor, a picture / video / sound, or the hex editor. */
|
||||
#viewer { max-width:1100px; }
|
||||
#viewer .dlg-h { flex-wrap:wrap; }
|
||||
/* Right to left so a long path is cut at its start, keeping the name in
|
||||
sight; still set to the left like any title. */
|
||||
.vpath { flex:1; min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; text-align:left;
|
||||
font-family:ui-monospace,Menlo,monospace; font-size:13px; font-weight:600; }
|
||||
.vmsg { width:100%; order:9; font-size:12px; }
|
||||
.vbody { padding:0; }
|
||||
#vcode .CodeMirror { height:70vh; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; }
|
||||
.vmedia { display:flex; align-items:center; justify-content:center; min-height:200px; max-height:75vh; padding:16px;
|
||||
background:#0c0f13 repeating-conic-gradient(#151a21 0 25%, #0c0f13 0 50%) 0 0/20px 20px; border-radius:0 0 12px 12px; }
|
||||
.vmedia img, .vmedia video { max-width:100%; max-height:calc(75vh - 32px); object-fit:contain; }
|
||||
.vmedia audio { width:min(100%, 520px); }
|
||||
/* Find and replace, over the editor: both fields the same width, the
|
||||
buttons of each row after its field. */
|
||||
.find { display:grid; grid-template-columns:minmax(0, 1fr) auto; gap:6px 8px; align-items:center;
|
||||
padding:8px 12px; border-bottom:1px solid var(--border); background:var(--panel); }
|
||||
.find .fin { position:relative; grid-column:1; }
|
||||
.find input { width:100%; height:32px; padding:4px 76px 4px 10px; font-size:13px; font-family:ui-monospace,Menlo,monospace; }
|
||||
.find #frep { padding-right:10px; }
|
||||
.find input.bad { border-color:var(--danger); }
|
||||
.find .cnt { position:absolute; right:10px; top:0; bottom:0; display:flex; align-items:center; pointer-events:none;
|
||||
color:var(--muted); font-size:12px; font-variant-numeric:tabular-nums; }
|
||||
.find .fbtns { grid-column:2; justify-self:end; display:flex; gap:6px; }
|
||||
.find button { height:32px; min-width:32px; padding:0 10px; font-size:13px;
|
||||
display:inline-flex; align-items:center; justify-content:center; }
|
||||
.find button.tg { background:transparent; border:1px solid var(--border); color:var(--muted); font-family:ui-monospace,Menlo,monospace; }
|
||||
.find button.tg.on { border-color:var(--accent); color:var(--text); background:#12202f; }
|
||||
.find #frow2, .find #frow2b { grid-row:2; }
|
||||
/* Documents and tables, drawn in the panel's colours. */
|
||||
.vdoc { height:75vh; overflow:auto; background:#0c0f13; border-radius:0 0 12px 12px; }
|
||||
/* SQLite editor. */
|
||||
.dbx { display:flex; flex-direction:column; height:100%; transition:opacity .2s; }
|
||||
.dbx.busy { opacity:.75; }
|
||||
.dbx-tabs { display:flex; gap:4px; padding:8px 10px 0; overflow-x:auto; flex:none; border-bottom:1px solid var(--border); }
|
||||
.dbx-tabs button { flex:none; background:transparent; color:var(--muted); border:1px solid transparent; border-bottom:0; border-radius:8px 8px 0 0;
|
||||
padding:6px 12px; font-size:13px; transition:background .15s, color .15s; }
|
||||
.dbx-tabs button:hover { color:var(--text); background:#151a21; }
|
||||
.dbx-tabs button.on { color:var(--text); background:var(--panel); border-color:var(--border); }
|
||||
.dbx-tabs .dbx-sqltab { margin-left:auto; font-family:ui-monospace,Menlo,monospace; }
|
||||
.dbx-tools { display:flex; align-items:center; gap:8px; padding:8px 10px; flex:none; flex-wrap:wrap; font-size:13px; }
|
||||
.dbx-tools[hidden], .dbx-pager[hidden], .dbx-grid[hidden], .dbx-sql[hidden], .dbx-tools button[hidden] { display:none; }
|
||||
.dbx-pager { display:inline-flex; align-items:center; gap:6px; }
|
||||
.dbx-sp { flex:1; }
|
||||
.dbx-grid { flex:1; min-height:0; overflow:auto; }
|
||||
.dbx-t { width:auto; border-collapse:separate; border-spacing:0; font-size:13px; font-variant-numeric:tabular-nums; }
|
||||
.dbx-t th, .dbx-t td { border-right:1px solid var(--border); border-bottom:1px solid var(--border); padding:5px 10px; text-align:left;
|
||||
max-width:420px; white-space:pre-wrap; overflow-wrap:anywhere; vertical-align:top; }
|
||||
.dbx-t thead th { position:sticky; top:0; z-index:1; background:var(--panel); color:var(--text); font-weight:500; white-space:nowrap; }
|
||||
.dbx-t thead th small { display:block; color:var(--muted); font-weight:400; font-size:11px; }
|
||||
.dbx-t td.num { text-align:right; }
|
||||
.dbx-t .dbx-k { color:var(--muted); text-align:right; }
|
||||
.dbx-t .dbx-ck { width:1%; text-align:center; }
|
||||
.dbx-t td.ed { cursor:text; transition:background .15s; }
|
||||
.dbx-t td.ed:hover { background:#151a21; }
|
||||
.dbx-t td.editing { padding:0; background:#12202f; }
|
||||
.dbx-t td.saving { opacity:.6; }
|
||||
.dbx-t td.flash-ok { animation:dbx-ok 1.2s ease; }
|
||||
.dbx-t td.flash-bad { animation:dbx-bad 1.2s ease; }
|
||||
@keyframes dbx-ok { 0% { background:rgba(55,200,113,.28); } 100% { background:transparent; } }
|
||||
@keyframes dbx-bad { 0% { background:rgba(255,92,92,.3); } 100% { background:transparent; } }
|
||||
.dbx-null { color:var(--muted); font-size:12px; }
|
||||
.dbx-edit { position:relative; }
|
||||
.dbx-edit textarea { display:block; width:100%; min-height:30px; resize:vertical; box-sizing:border-box; margin:0; padding:5px 28px 5px 10px; border:1px solid var(--accent);
|
||||
border-radius:0; background:#0c0f13; color:var(--text); font:inherit; outline:none; }
|
||||
.dbx-nullb { position:absolute; top:4px; right:4px; width:20px; height:20px; padding:0; font-size:12px; line-height:1;
|
||||
background:#1b222c; color:var(--muted); border:1px solid var(--border); border-radius:5px; }
|
||||
.dbx-nullb:hover { color:var(--text); background:#232b36; }
|
||||
.dbx-t tr.dbx-new td { padding:3px; background:#12202f; }
|
||||
.dbx-t tr.dbx-new input { width:100%; min-width:90px; height:28px; padding:3px 8px; font-size:13px; }
|
||||
.dbx-t tr.dbx-newbar td { background:#12202f; padding:6px 8px; white-space:nowrap; }
|
||||
.dbx-sql { flex:1; min-height:0; display:flex; flex-direction:column; gap:8px; padding:10px; }
|
||||
.dbx-q { flex:none; height:110px; resize:vertical; font:13px/1.5 ui-monospace,Menlo,monospace; background:#0c0f13; color:var(--text);
|
||||
border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||||
.dbx-q:focus { border-color:var(--accent); outline:none; }
|
||||
.dbx-sqlbar { display:flex; align-items:center; gap:10px; font-size:13px; }
|
||||
.dbx-sqlmsg.bad { color:var(--danger); }
|
||||
.dbx-sqlres { flex:1; min-height:0; overflow:auto; }
|
||||
.vdoc .dv-pdf { display:block; width:100%; height:100%; border:0; background:#525659; }
|
||||
.vdoc .dv-html { display:block; width:100%; height:100%; border:0; background:#fff; }
|
||||
.dv-doc { max-width:820px; margin:0 auto; padding:28px 32px 40px; font-size:15px; line-height:1.65; color:var(--text); overflow-wrap:anywhere; }
|
||||
.dv-doc p { margin:0 0 .7em; }
|
||||
.dv-doc h1, .dv-doc h2, .dv-doc h3, .dv-doc h4, .dv-doc h5, .dv-doc h6 { margin:1.2em 0 .5em; line-height:1.3; }
|
||||
.dv-doc h1 { font-size:26px; } .dv-doc h2 { font-size:21px; } .dv-doc h3 { font-size:18px; } .dv-doc h4, .dv-doc h5, .dv-doc h6 { font-size:16px; }
|
||||
.dv-doc ul { margin:0 0 .7em; padding-left:1.4em; }
|
||||
.dv-doc li p { margin:0; }
|
||||
.dv-doc .dv-link { color:var(--accent); }
|
||||
.dv-table { border-collapse:collapse; margin:.4em 0 1em; width:auto; }
|
||||
.dv-table td { border:1px solid var(--border); padding:6px 10px; vertical-align:top; font-size:14px; }
|
||||
.dv-table td p { margin:0; }
|
||||
.dv-scroll { overflow:auto; }
|
||||
.dv-grid { max-height:100%; }
|
||||
.dv-sheet { width:auto; border-collapse:separate; border-spacing:0; font-size:13px; font-variant-numeric:tabular-nums; }
|
||||
.dv-sheet th, .dv-sheet td { border-right:1px solid var(--border); border-bottom:1px solid var(--border); padding:4px 10px;
|
||||
white-space:nowrap; max-width:360px; overflow:hidden; text-overflow:ellipsis; text-align:left; }
|
||||
.dv-sheet thead th { position:sticky; top:0; z-index:1; background:var(--panel); color:var(--muted); font-weight:500; text-align:center; }
|
||||
.dv-sheet tbody th { position:sticky; left:0; background:var(--panel); color:var(--muted); font-weight:500; text-align:right; }
|
||||
.dv-sheet thead th:first-child { left:0; z-index:2; }
|
||||
.dv-sheet td.n { text-align:right; }
|
||||
.dv-tabs { position:sticky; top:0; z-index:3; display:flex; gap:6px; flex-wrap:wrap; padding:8px 12px; background:var(--panel); border-bottom:1px solid var(--border); }
|
||||
.dv-tabs button { height:28px; padding:0 12px; font-size:13px; background:transparent; border:1px solid var(--border); color:var(--muted); }
|
||||
.dv-tabs button.on { border-color:var(--accent); color:var(--text); background:#12202f; }
|
||||
.dv-note { color:var(--muted); font-size:13px; padding:12px 16px; margin:0; }
|
||||
/* Hex editor: offset, 16 bytes, the same as text. Only the rows in sight are drawn. */
|
||||
.hexed { position:relative; height:70vh; overflow:auto; outline:none; background:#0c0f13;
|
||||
font:13px/20px ui-monospace,SFMono-Regular,Menlo,monospace; }
|
||||
.hexed .hx-rows { position:absolute; left:0; right:0; padding:0 12px; }
|
||||
.hx-r { display:flex; gap:18px; height:20px; white-space:pre; }
|
||||
.hx-o { color:var(--muted); }
|
||||
.hx-b i, .hx-a i { font-style:normal; cursor:text; }
|
||||
.hx-b i { margin-right:6px; }
|
||||
.hx-b i:nth-child(8) { margin-right:14px; }
|
||||
.hx-a { color:#9aa4b2; }
|
||||
.hx-r i.ch { color:#ffcb6b; }
|
||||
.hx-r i.cur { background:var(--accent); color:#fff; border-radius:2px; }
|
||||
.hx-r i.cur2 { outline:1px solid var(--accent); border-radius:2px; }
|
||||
/* Editor colours, in the panel's palette. */
|
||||
.cm-s-lpra.CodeMirror { background:#0c0f13; color:#d7dce4; }
|
||||
.cm-s-lpra .CodeMirror-gutters { background:#0c0f13; border-right:1px solid var(--border); }
|
||||
.cm-s-lpra .CodeMirror-linenumber { color:#4b5563; }
|
||||
.cm-s-lpra .CodeMirror-cursor { border-left:2px solid #e6e9ef; }
|
||||
.cm-s-lpra .CodeMirror-selected, .cm-s-lpra.CodeMirror-focused .CodeMirror-selected { background:rgba(79,157,255,.28); }
|
||||
.cm-s-lpra .CodeMirror-line::selection, .cm-s-lpra .CodeMirror-line > span::selection { background:rgba(79,157,255,.28); }
|
||||
.cm-s-lpra span.cm-keyword { color:#c792ea; }
|
||||
.cm-s-lpra span.cm-def { color:#82aaff; }
|
||||
.cm-s-lpra span.cm-variable-2, .cm-s-lpra span.cm-variable-3, .cm-s-lpra span.cm-type { color:#ffcb6b; }
|
||||
.cm-s-lpra span.cm-builtin { color:#ffcb6b; }
|
||||
.cm-s-lpra span.cm-string, .cm-s-lpra span.cm-string-2 { color:#c3e88d; }
|
||||
.cm-s-lpra span.cm-number, .cm-s-lpra span.cm-atom { color:#f78c6c; }
|
||||
.cm-s-lpra span.cm-comment { color:#6b7686; font-style:italic; }
|
||||
.cm-s-lpra span.cm-meta, .cm-s-lpra span.cm-qualifier { color:#89ddff; }
|
||||
.cm-s-lpra span.cm-operator { color:#89ddff; }
|
||||
.cm-s-lpra span.cm-property { color:#82aaff; }
|
||||
.cm-s-lpra span.cm-tag { color:#f07178; }
|
||||
.cm-s-lpra span.cm-attribute { color:#ffcb6b; }
|
||||
.cm-s-lpra span.cm-header { color:#82aaff; font-weight:600; }
|
||||
.cm-s-lpra span.cm-link { color:#89ddff; text-decoration:underline; }
|
||||
.cm-s-lpra span.cm-error { color:#ff5c5c; }
|
||||
.cm-s-lpra span.cm-positive { color:#37c871; }
|
||||
.cm-s-lpra span.cm-negative { color:#ff5c5c; }
|
||||
.cm-s-lpra .CodeMirror-matchingbracket { color:#fff !important; outline:1px solid #4f9dff; }
|
||||
.cm-s-lpra .cm-searching { background:rgba(255,203,107,.32); border-radius:2px; }
|
||||
@media (max-width: 640px) {
|
||||
#vcode .CodeMirror, .hexed { height:calc(100vh - 220px); }
|
||||
.hexed { font-size:11px; }
|
||||
.hx-r { gap:10px; }
|
||||
.hx-b i { margin-right:3px; }
|
||||
.hx-b i:nth-child(8) { margin-right:8px; }
|
||||
}
|
||||
.toast { z-index:50; position:fixed; bottom:18px; left:50%; transform:translateX(-50%);
|
||||
background:#2a2f3a; border:1px solid var(--border); border-radius:8px;
|
||||
padding:10px 16px; font-size:14px; opacity:0; transition:opacity .2s; pointer-events:none; }
|
||||
.toast.show { opacity:1; }
|
||||
.toast.err { background:#3a1d1d; border-color:#6b2b2b; color:#ffb3b3; }
|
||||
/* A mid-width file pane drops the time column for the names. */
|
||||
.fpane.mid table.fm .c-mt, .fpane.mid table.fm td.mt { display:none; }
|
||||
/* A file pane narrower than ~520 px (a split tab) folds as on a phone. */
|
||||
.fpane.narrow table.fm .c-mt, .fpane.narrow table.fm td.mt { display:none; }
|
||||
.fpane.narrow table.fm .c-sz { width:auto; white-space:nowrap; }
|
||||
.fpane.narrow table.fm .c-act { width:1%; }
|
||||
.fpane.narrow table.fm td, .fpane.narrow table.fm th { padding:7px 4px; }
|
||||
.fpane.narrow .fm-bar .fm-pathbox { flex:1 1 100%; order:-1; }
|
||||
.fpane.narrow .fm-bar .spacer { flex:1 1 100%; height:0; }
|
||||
/* The pane: its bar and list scroll inside it. */
|
||||
.fpane-body { flex:1; min-height:0; overflow:auto; padding:10px 12px 12px; }
|
||||
.tabview:not(.split) .fpane-body { padding:12px 16px 16px; }
|
||||
/* A lone file pane: its first bar keeps clear of the corner "+". */
|
||||
.tabview:not(.split) .fpane .fm-bar:first-child, body.split1 .fpane .fm-bar:first-child { margin-right:40px; }
|
||||
@@ -0,0 +1,369 @@
|
||||
// File panes of the workspace: a folder of a machine - or of an SSH host
|
||||
// through one - as a list to go through, with uploads, new folders and files,
|
||||
// zip and delete. Each pane keeps its own folder, list and selection; the
|
||||
// dialogs (viewer, properties, move) are shared and live in fileviews.js.
|
||||
// A pane's data source does the work: MachineSource here, SSHSource for
|
||||
// files over SSH.
|
||||
(function () {
|
||||
'use strict';
|
||||
const FV = window.FileViews;
|
||||
const {toast, join, parent, fmtSize, fmtTime, esc} = FV;
|
||||
const X = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
|
||||
|
||||
// Monochrome inline icons (inherit button color via currentColor).
|
||||
const SVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
|
||||
const ICON_DL = SVG + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
|
||||
const ICON_UNZIP = SVG + '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>';
|
||||
const ICON_ZIP = SVG + '<path d="M21 8v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8"/><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M10 12h4"/></svg>';
|
||||
const ICON_DEL = SVG + '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
||||
// File icons: one colour, the line style of the buttons; a few kinds by
|
||||
// the name's ending.
|
||||
const FI = '<svg class="fi" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">';
|
||||
const FICONS = {
|
||||
dir: FI + '<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
|
||||
file: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>',
|
||||
text: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
|
||||
code: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M10 12l-2 2.5 2 2.5M14 12l2 2.5-2 2.5"/></svg>',
|
||||
img: FI + '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 9"/></svg>',
|
||||
arc: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M11 4v2M11 8v2M11 12v2"/><rect x="9.5" y="15" width="3" height="3" rx=".6"/></svg>',
|
||||
media:FI + '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z"/></svg>',
|
||||
link: FI + '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>'
|
||||
};
|
||||
function fileIcon(e) {
|
||||
let k = 'file';
|
||||
if (e.is_dir) k = 'dir';
|
||||
else if (/^L/.test(e.mode || '')) k = 'link';
|
||||
else if (/\.(png|jpe?g|gif|webp|bmp|ico|svg|avif|heic)$/i.test(e.name)) k = 'img';
|
||||
else if (/\.(zip|tar|gz|tgz|bz2|xz|zst|7z|rar|deb|rpm)$/i.test(e.name)) k = 'arc';
|
||||
else if (/\.(mp4|m4v|webm|mov|mkv|avi|mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(e.name)) k = 'media';
|
||||
else if (/\.(js|ts|go|py|rb|rs|c|h|cpp|java|kt|php|sh|bash|zsh|json|ya?ml|toml|html?|css|sql|lua|pl|swift|vue|jsx|tsx|ini|conf|xml)$/i.test(e.name)) k = 'code';
|
||||
else if (/\.(txt|md|log|csv|rst|cfg|env)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text';
|
||||
return '<span class="ic' + (k === 'dir' ? ' dir' : '') + '">' + FICONS[k] + '</span>';
|
||||
}
|
||||
|
||||
// A machine's files: the panel's file API of that machine.
|
||||
class MachineSource {
|
||||
constructor(agent) { this.agent = agent; this.caps = {zip: true, sqlite: true}; }
|
||||
url(what, path) { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + what + '?path=' + encodeURIComponent(path); }
|
||||
op(name, body) {
|
||||
return fetch('/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + name, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body || {}), credentials: 'same-origin'
|
||||
}).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).catch(() => ({ok: false, error: 'ошибка сети'}));
|
||||
}
|
||||
home() { return '~' + (this.agent.user || ''); }
|
||||
close() {}
|
||||
}
|
||||
|
||||
const UP_CAP = 512 * 1024 * 1024;
|
||||
const SORT_DEF = {by: 'name', dir: 1};
|
||||
const byName = (a, b) => a.name.localeCompare(b.name, undefined, {numeric: true, sensitivity: 'base'}) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
||||
|
||||
class FilePane {
|
||||
// o: {kind: 'agent'|'ssh', agent, src} - src made by the caller for SSH.
|
||||
constructor(o) {
|
||||
this.agent = o.agent;
|
||||
this.src = o.src || new MachineSource(o.agent);
|
||||
this.kind = o.kind === 'ssh' ? 'sshfiles' : 'files';
|
||||
this.ssh = o.ssh || null;
|
||||
this.source = o.kind === 'ssh'
|
||||
? {kind: 'ssh', id: o.agent.id + '|' + o.ssh.user + '@' + o.ssh.host + ':' + (o.ssh.port || 22), label: o.ssh.user + '@' + o.ssh.host}
|
||||
: {kind: 'agent', id: o.agent.id, label: o.agent.name};
|
||||
this.cwd = '/';
|
||||
this.entries = [];
|
||||
this.selected = new Set();
|
||||
this.uploads = [];
|
||||
this.upActive = false;
|
||||
this.busyN = 0;
|
||||
this.state = 'wait';
|
||||
this.text = 'загрузка…';
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'pane fpane';
|
||||
this.el.innerHTML =
|
||||
'<div class="pane-head"><span class="dot wait"></span>' + (this.ssh ? '<span class="badge-ssh">SSH</span>' : '') +
|
||||
'<span class="pane-title"></span><button type="button" class="pane-x" title="Закрыть" aria-label="Закрыть">' + X + '</button></div>' +
|
||||
'<div class="fpane-body">' +
|
||||
'<div class="fm-bar">' +
|
||||
' <button class="btn ghost small ico fp-up" type="button"><svg width="14" height="14" 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="Папка">' +
|
||||
' <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 class="btn ghost small fp-refresh" type="button">Обновить</button>' +
|
||||
'</div>' +
|
||||
'<div class="fm-bar">' +
|
||||
' <button class="btn small ico fp-mkdir" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><path d="M12 11v6M9 14h6"/></svg>Папка</button>' +
|
||||
' <button class="btn small ico fp-newfile" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M12 12v6M9 15h6"/></svg>Файл</button>' +
|
||||
' <label class="btn small ico" style="cursor:pointer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 10l6 6 6-6M5 21h14"/></svg>Загрузить<input type="file" class="fp-upl" multiple hidden></label>' +
|
||||
// What is done to the ticked rows: two small icons on the right,
|
||||
// their words on hover, the count beside them.
|
||||
' <span class="spacer"></span>' +
|
||||
' <span class="fp-sel"><span class="muted fp-selinfo"></span>' +
|
||||
' <button class="btn ghost small icon sq fp-zip" type="button" disabled title="В zip" aria-label="В zip">' + ICON_ZIP + '</button>' +
|
||||
' <button class="btn danger small icon sq fp-delsel" type="button" disabled title="Удалить выбранное" aria-label="Удалить выбранное">' + ICON_DEL + '</button></span>' +
|
||||
'</div>' +
|
||||
'<div class="card fp-drop" style="padding:0"><table class="fm"><thead><tr>' +
|
||||
' <th style="width:28px"><input type="checkbox" class="fp-all" aria-label="Выбрать все"></th>' +
|
||||
' <th class="srt" data-sort="name">Имя</th><th class="sz c-sz srt" data-sort="size">Размер</th>' +
|
||||
' <th class="sz c-mt srt" data-sort="mtime">Изменён</th><th class="c-act"></th>' +
|
||||
'</tr></thead><tbody class="fp-list"></tbody></table></div>' +
|
||||
'<p class="muted fp-msg" hidden></p>' +
|
||||
'</div>';
|
||||
if (!this.src.caps.zip) this.q('.fp-zip').hidden = true;
|
||||
this.bind();
|
||||
// Narrow panes (a split tab) fold like the phone layout.
|
||||
// A mid-width pane gives the time's room to the names.
|
||||
if (window.ResizeObserver) new ResizeObserver(() => {
|
||||
const w = this.el.clientWidth;
|
||||
this.el.classList.toggle('narrow', w < 520);
|
||||
this.el.classList.toggle('mid', w < 760);
|
||||
}).observe(this.el);
|
||||
this.start();
|
||||
}
|
||||
q(sel) { return this.el.querySelector(sel); }
|
||||
|
||||
// ---- the pane interface of the workspace ----
|
||||
title() { return (this.ssh ? this.ssh.host : this.agent.name) + ' · ' + (this.cwd === '/' ? '/' : this.cwd.replace(/\/+$/, '').split('/').pop()); }
|
||||
tooltip() { return (this.ssh ? this.source.label : this.agent.name) + ':' + this.cwd; }
|
||||
live() { return false; }
|
||||
fit() {}
|
||||
focus() { this.q('.fp-path').focus({preventScroll: true}); }
|
||||
close() { this.src.close(); this.el.remove(); }
|
||||
selectedPaths() { return [...this.selected].map(n => join(this.cwd, n)); }
|
||||
// dropTarget: a folder row under the point, else this folder.
|
||||
dropTarget(x, y) {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
const tr = el && el.closest && el.closest('tr[data-dir]');
|
||||
if (tr && this.el.contains(tr)) return {kind: 'folder', source: this.source, dir: join(this.cwd, tr.dataset.dir), pane: this};
|
||||
return {kind: 'files', source: this.source, dir: this.cwd, pane: this};
|
||||
}
|
||||
|
||||
setState(state, text) {
|
||||
this.state = state; this.text = text;
|
||||
this.q('.pane-head .dot').className = 'dot ' + state;
|
||||
this.q('.pane-title').textContent = this.title();
|
||||
this.q('.pane-title').title = this.tooltip();
|
||||
if (window.Workspace) Workspace.renderPane(this);
|
||||
}
|
||||
setBusy(on) {
|
||||
this.busyN = Math.max(0, this.busyN + (on ? 1 : -1));
|
||||
this.q('.fp-busy').classList.toggle('on', this.busyN > 0);
|
||||
}
|
||||
async api(op, body) {
|
||||
this.setBusy(true);
|
||||
try { return await this.src.op(op, body); } finally { this.setBusy(false); }
|
||||
}
|
||||
message(text) {
|
||||
const m = this.q('.fp-msg');
|
||||
m.hidden = !text;
|
||||
m.textContent = text || '';
|
||||
}
|
||||
|
||||
async start() {
|
||||
// Start in the home folder of the machine's user (or the SSH home);
|
||||
// a listing that fails there falls back to the root.
|
||||
const r = await this.api('list', {path: this.src.home()});
|
||||
if (r.ok) this.show(r.data); else this.go('/');
|
||||
}
|
||||
show(d) {
|
||||
this.cwd = d.path; this.entries = d.entries || []; this.selected.clear();
|
||||
this.q('.fp-path').value = this.cwd;
|
||||
this.message('');
|
||||
this.render();
|
||||
this.setState('ok', this.cwd);
|
||||
}
|
||||
async go(path) {
|
||||
const r = await this.api('list', {path});
|
||||
if (!r.ok) {
|
||||
toast(r.error || 'ошибка', true);
|
||||
if (/офлайн|нет доступа/.test(r.error || '')) { this.message(r.error); this.setState('err', r.error); }
|
||||
this.q('.fp-path').value = this.cwd;
|
||||
return false;
|
||||
}
|
||||
this.show(r.data);
|
||||
return true;
|
||||
}
|
||||
reload() { return this.go(this.cwd); }
|
||||
|
||||
// Sorting: by name, size or time, folders always ahead of files. Kept in
|
||||
// this browser for each folder of each place.
|
||||
sortKey() { return 'lpra.sort:' + this.source.id + ':' + this.cwd; }
|
||||
getSort() {
|
||||
try { const v = JSON.parse(localStorage.getItem(this.sortKey())); if (v && ['name', 'size', 'mtime'].includes(v.by)) return v; } catch (e) { /* default */ }
|
||||
return SORT_DEF;
|
||||
}
|
||||
setSort(v) {
|
||||
try {
|
||||
if (v.by === SORT_DEF.by && v.dir === SORT_DEF.dir) localStorage.removeItem(this.sortKey());
|
||||
else localStorage.setItem(this.sortKey(), JSON.stringify(v));
|
||||
} catch (e) { /* not kept */ }
|
||||
}
|
||||
sorted() {
|
||||
const s = this.getSort();
|
||||
const key = s.by === 'size' ? e => e.size || 0 : s.by === 'mtime' ? e => e.mtime || 0 : null;
|
||||
return this.entries.slice().sort((a, b) => {
|
||||
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
||||
const d = key ? key(a) - key(b) : 0;
|
||||
return (d || byName(a, b)) * s.dir;
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const tb = this.q('.fp-list');
|
||||
tb.innerHTML = '';
|
||||
this.updateSel();
|
||||
for (const e of this.sorted()) {
|
||||
const tr = document.createElement('tr');
|
||||
if (e.is_dir) tr.dataset.dir = e.name;
|
||||
tr.dataset.name = e.name;
|
||||
const isZip = this.src.caps.zip && !e.is_dir && /\.zip$/i.test(e.name);
|
||||
tr.innerHTML =
|
||||
'<td><input type="checkbox" class="ck" aria-label="Выбрать"></td>' +
|
||||
'<td class="nm">' + fileIcon(e) + esc(e.name) + '</td>' +
|
||||
'<td class="sz c-sz">' + (e.is_dir ? '' : fmtSize(e.size)) + '</td>' +
|
||||
'<td class="sz mt">' + fmtTime(e.mtime) + '</td>' +
|
||||
'<td><div class="act">' +
|
||||
(isZip ? '<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">' + ICON_UNZIP + '</button>' : '') +
|
||||
(e.is_dir ? '' : '<a class="btn ghost small icon" title="Скачать" download>' + ICON_DL + '</a>') +
|
||||
'<button class="btn ghost small icon" data-act="ren" title="Переместить или копировать">' + ICON_MV + '</button>' +
|
||||
'<button class="btn danger small icon" data-act="del" title="Удалить">' + ICON_DEL + '</button>' +
|
||||
'</div></td>';
|
||||
const dl = tr.querySelector('a[download]');
|
||||
if (dl) dl.href = this.src.url('download', join(this.cwd, e.name));
|
||||
tr.querySelector('.nm').onclick = () => e.is_dir ? this.go(join(this.cwd, e.name)) : FV.view(this, e);
|
||||
tr.querySelector('.ck').checked = this.selected.has(e.name);
|
||||
tr.querySelector('.ck').onchange = ev => { ev.target.checked ? this.selected.add(e.name) : this.selected.delete(e.name); this.updateSel(); };
|
||||
tr.querySelector('[data-act="del"]').onclick = () => FV.del(this, e);
|
||||
tr.querySelector('[data-act="ren"]').onclick = () => FV.move(this, e);
|
||||
const uz = tr.querySelector('[data-act="unzip"]');
|
||||
if (uz) uz.onclick = () => this.unzip(e);
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
}
|
||||
updateSel() {
|
||||
const n = this.selected.size;
|
||||
this.q('.fp-selinfo').textContent = n ? 'Выбрано: ' + n : '';
|
||||
this.q('.fp-zip').disabled = !n;
|
||||
this.q('.fp-delsel').disabled = !n;
|
||||
}
|
||||
async unzip(e) {
|
||||
const r = await this.api('unzip', {path: join(this.cwd, e.name), dest: this.cwd});
|
||||
r.ok ? (toast('распаковано'), this.reload()) : toast(r.error || 'ошибка', true);
|
||||
}
|
||||
|
||||
bind() {
|
||||
const q = s => this.q(s);
|
||||
q('.pane-x').onclick = ev => { ev.stopPropagation(); Workspace.closePane(this); };
|
||||
q('.pane-head').addEventListener('pointerdown', ev => { if (Workspace.desk && !ev.target.closest('.pane-x')) Workspace.startDrag(ev, 'pane', this); });
|
||||
this.el.addEventListener('pointerdown', () => Workspace.setActivePane(this), true);
|
||||
q('.fp-up').onclick = () => this.go(parent(this.cwd));
|
||||
q('.fp-refresh').onclick = () => this.reload();
|
||||
q('.fp-dirprops').onclick = () => FV.props(this, this.cwd);
|
||||
q('.fp-path').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); this.go((q('.fp-path').value || '').trim() || '/'); }
|
||||
});
|
||||
this.el.querySelectorAll('table.fm th.srt').forEach(th => th.onclick = () => {
|
||||
const s = this.getSort(), by = th.dataset.sort;
|
||||
// A new column starts the way it is most often wanted: names A to Z,
|
||||
// sizes and times biggest and newest first.
|
||||
this.setSort(s.by === by ? {by, dir: -s.dir} : {by, dir: by === 'name' ? 1 : -1});
|
||||
this.render();
|
||||
});
|
||||
q('.fp-mkdir').onclick = async () => {
|
||||
const n = prompt('Имя новой папки:'); if (!n) return;
|
||||
const r = await this.api('mkdir', {path: join(this.cwd, n)});
|
||||
r.ok ? (toast('создано'), this.reload()) : toast(r.error || 'ошибка', true);
|
||||
};
|
||||
q('.fp-newfile').onclick = async () => {
|
||||
const n = prompt('Имя нового файла:'); if (!n) return;
|
||||
const r = await this.api('write', {path: join(this.cwd, n), data: ''});
|
||||
r.ok ? (toast('создано'), this.reload()) : toast(r.error || 'ошибка', true);
|
||||
};
|
||||
q('.fp-delsel').onclick = async () => {
|
||||
if (!confirm('Удалить выбранное (' + this.selected.size + ')?')) return;
|
||||
for (const n of this.selected) {
|
||||
const e = this.entries.find(x => x.name === n);
|
||||
await this.api(e && e.is_dir ? 'deltree' : 'delete', {path: join(this.cwd, n)});
|
||||
}
|
||||
toast('удалено'); this.reload();
|
||||
};
|
||||
q('.fp-zip').onclick = async () => {
|
||||
const name = prompt('Имя архива:', 'archive.zip'); if (!name) return;
|
||||
const r = await this.api('zip', {paths: this.selectedPaths(), dest: join(this.cwd, name)});
|
||||
r.ok ? (toast('архив создан'), this.reload()) : toast(r.error || 'ошибка', true);
|
||||
};
|
||||
q('.fp-all').onchange = ev => {
|
||||
this.selected.clear();
|
||||
if (ev.target.checked) this.entries.forEach(e => this.selected.add(e.name));
|
||||
this.el.querySelectorAll('.fp-list .ck').forEach(c => { c.checked = ev.target.checked; });
|
||||
this.updateSel();
|
||||
};
|
||||
q('.fp-busy').onclick = () => { if (this.uploads.length) FV.uploads(this); };
|
||||
q('.fp-upl').onchange = ev => { this.upload(ev.target.files); ev.target.value = ''; };
|
||||
const drop = q('.fp-drop');
|
||||
// Files from the computer dropped on the list go up here.
|
||||
const fromOS = e => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
|
||||
['dragenter', 'dragover'].forEach(x => drop.addEventListener(x, e => { if (!fromOS(e)) return; e.preventDefault(); drop.classList.add('drag'); }));
|
||||
drop.addEventListener('dragleave', () => drop.classList.remove('drag'));
|
||||
drop.addEventListener('drop', e => {
|
||||
if (!fromOS(e)) return;
|
||||
e.preventDefault();
|
||||
drop.classList.remove('drag');
|
||||
if (e.dataTransfer.files.length) this.upload(e.dataTransfer.files);
|
||||
});
|
||||
}
|
||||
|
||||
// Uploads: one file at a time, each with its progress. What has gone up
|
||||
// shows as a percentage by the spinner; a click on it lists the files.
|
||||
renderUploads() {
|
||||
const ups = this.uploads;
|
||||
const pend = ups.filter(u => u.state === 'wait' || u.state === 'send' || u.state === 'write');
|
||||
const counted = ups.filter(u => !u.skip);
|
||||
const total = counted.reduce((n, u) => n + u.size, 0) || 1;
|
||||
const sent = counted.reduce((n, u) => n + (u.state === 'wait' ? 0 : u.state === 'send' ? u.size * u.frac : u.size), 0);
|
||||
const busy = this.q('.fp-busy'), pct = this.q('.fp-uppct');
|
||||
busy.classList.toggle('up', ups.length > 0);
|
||||
busy.title = ups.length ? 'Показать загружаемые файлы' : '';
|
||||
if (pend.length) pct.textContent = Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '');
|
||||
pct.classList.toggle('on', pend.length > 0);
|
||||
FV.uploadsChanged(this);
|
||||
}
|
||||
async upload(files) {
|
||||
clearTimeout(this.upDoneTimer);
|
||||
// A new batch starts a fresh list once the last one is over.
|
||||
if (!this.upActive) this.uploads.length = 0;
|
||||
const dir = this.cwd;
|
||||
const batch = [...files].map(f => f.size > UP_CAP
|
||||
? {file: f, name: f.name, size: f.size, dir, state: 'fail', frac: 0, error: 'больше 512 МБ', skip: true}
|
||||
: {file: f, name: f.name, size: f.size, dir, state: 'wait', frac: 0, error: ''});
|
||||
this.uploads.push(...batch);
|
||||
this.renderUploads();
|
||||
if (this.upActive) return;
|
||||
this.upActive = true;
|
||||
this.setBusy(true);
|
||||
let failed = 0;
|
||||
for (let u; (u = this.uploads.find(x => x.state === 'wait')); ) {
|
||||
u.state = 'send'; this.renderUploads();
|
||||
const r = await FV.sendFile(this.src, join(u.dir, u.name), u.file, frac => {
|
||||
u.frac = frac;
|
||||
if (frac >= 1 && u.state === 'send') u.state = 'write';
|
||||
this.renderUploads();
|
||||
});
|
||||
if (r.ok) u.state = 'done';
|
||||
else { u.state = 'fail'; u.error = r.error || 'ошибка'; failed++; }
|
||||
this.renderUploads();
|
||||
if (u.dir === this.cwd) this.reload();
|
||||
}
|
||||
this.setBusy(false);
|
||||
this.upActive = false;
|
||||
failed += this.uploads.filter(u => u.skip).length;
|
||||
toast(failed ? 'не загружено: ' + failed : 'загружено', !!failed);
|
||||
this.renderUploads();
|
||||
// The list stays a while for a look, then the indicator goes quiet.
|
||||
this.upDoneTimer = setTimeout(() => { if (!this.upActive) { this.uploads.length = 0; this.renderUploads(); } }, failed ? 30000 : 8000);
|
||||
}
|
||||
}
|
||||
|
||||
window.FilePane = FilePane;
|
||||
window.MachineSource = MachineSource;
|
||||
Workspace.paneTypes.files = agent => new FilePane({kind: 'agent', agent});
|
||||
})();
|
||||
@@ -0,0 +1,929 @@
|
||||
// The dialogs of the file panes: the viewer and editor, the hex editor,
|
||||
// properties, moving and copying, the list of uploads. One of each on the
|
||||
// page; each works for the pane it was opened from (cur), whose data source
|
||||
// - a machine, or an SSH host through one - does the work.
|
||||
(function () {
|
||||
'use strict';
|
||||
let cur = null; // the pane the open dialog works for
|
||||
const api = (op, body) => cur.src.op(op, body);
|
||||
const EDIT_LIMIT = (window.APP && window.APP.editLimit) || 4 << 20;
|
||||
const $ = id => document.getElementById(id);
|
||||
let toastTimer = null;
|
||||
// Opens a dialog with the focus on the dialog itself, not its first button.
|
||||
const openModal = d => { d.showModal(); d.focus(); };
|
||||
const toast = (msg, err) => {
|
||||
const t = $('toast'), host = [...document.querySelectorAll('dialog[open]')].pop() || document.body;
|
||||
if (t.parentNode !== host) host.appendChild(t);
|
||||
t.textContent = msg;
|
||||
t.className = 'toast show' + (err ? ' err' : '');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.className = 'toast', err ? 3500 : 2200);
|
||||
};
|
||||
const b64ToBytes = b64 => { const bin=atob(b64); const u=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++)u[i]=bin.charCodeAt(i); return u; };
|
||||
const join = (dir, name) => (dir.endsWith('/') ? dir : dir+'/') + name;
|
||||
const parent = p => { if(p==='/'||p==='') return '/'; const i=p.replace(/\/+$/,'').lastIndexOf('/'); return i<=0 ? '/' : p.slice(0,i); };
|
||||
const fmtSize = n => { if(n<1024)return n+' B'; if(n<1048576)return (n/1024).toFixed(1)+' K'; if(n<1073741824)return (n/1048576).toFixed(1)+' M'; return (n/1073741824).toFixed(1)+' G'; };
|
||||
const fmtTime = t => t ? new Date(t*1000).toLocaleString() : '';
|
||||
const esc = s => s.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
|
||||
|
||||
// Sends a file to the client in pieces: each a request of its own (a proxy
|
||||
// in front takes only so much at once), a few in flight together so a good
|
||||
// link stays busy, and one the link drops is sent again alone. onFrac gets
|
||||
// how much has gone; the answer is the client's, once it has written it.
|
||||
const SEND_WORKERS = 4;
|
||||
async function sendFile(src, path, blob, onFrac) {
|
||||
let st;
|
||||
try {
|
||||
st = await fetch(src.url('upload', path) + '&size=' + blob.size, {method: 'POST'}).then(r => r.json());
|
||||
} catch (e) { return {ok: false, error: 'ошибка сети'}; }
|
||||
if (!st.ok) return st;
|
||||
const {id, chunk} = st.data;
|
||||
const xurl = '/api/panel/fs/xfer/' + encodeURIComponent(id);
|
||||
const n = Math.max(1, Math.ceil(blob.size / chunk));
|
||||
const sent = new Array(n).fill(0);
|
||||
const report = () => onFrac && onFrac(blob.size ? Math.min(1, sent.reduce((a, b) => a + b, 0) / blob.size) : 1);
|
||||
let next = 0, failed = null;
|
||||
const putPiece = (i, part) => new Promise(resolve => {
|
||||
const x = new XMLHttpRequest();
|
||||
x.open('PUT', xurl + '/' + i);
|
||||
x.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
x.upload.onprogress = ev => { sent[i] = ev.loaded; report(); };
|
||||
x.onload = () => resolve({status: x.status, text: x.responseText});
|
||||
x.onerror = x.ontimeout = () => resolve({status: 0});
|
||||
x.timeout = 5 * 60 * 1000;
|
||||
x.send(part);
|
||||
});
|
||||
async function worker() {
|
||||
while (!failed && next < n) {
|
||||
const i = next++, part = blob.slice(i * chunk, (i + 1) * chunk);
|
||||
for (let tries = 0; !failed; ) {
|
||||
const r = await putPiece(i, part);
|
||||
if (r.status === 204) { sent[i] = part.size; report(); break; }
|
||||
if (r.status === 410 || r.status === 400) { failed = (r.text || '').trim() || 'передача прервана'; break; }
|
||||
sent[i] = 0; report();
|
||||
if (r.status === 503) continue; // the client is busy: just again
|
||||
if (++tries >= 30) { failed = 'сеть не держит передачу'; break; }
|
||||
await new Promise(res => setTimeout(res, Math.min(tries, 10) * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length: Math.min(SEND_WORKERS, n)}, worker));
|
||||
if (failed) {
|
||||
fetch(xurl + '/cancel', {method: 'POST'}).catch(() => {});
|
||||
return {ok: false, error: failed};
|
||||
}
|
||||
for (let tries = 0; ; ) {
|
||||
try {
|
||||
const r = await fetch(xurl + '/result', {cache: 'no-store'}).then(r => r.json());
|
||||
if (r.done) return r.result;
|
||||
tries = 0;
|
||||
} catch (e) {
|
||||
if (++tries >= 30) return {ok: false, error: 'нет ответа о записи файла'};
|
||||
await new Promise(res => setTimeout(res, 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Writes a file as it is, with no base64: the request body is the file.
|
||||
async function putFile(path, body) {
|
||||
// The save button turns into a small spinner meanwhile, and takes no
|
||||
// second click.
|
||||
const btn = $('save');
|
||||
if (btn.classList.contains('saving')) return {ok: false, error: 'сохранение уже идёт'};
|
||||
btn.classList.add('saving');
|
||||
btn.disabled = true;
|
||||
cur.setBusy(true);
|
||||
try {
|
||||
return await sendFile(cur.src, path, new Blob([body]));
|
||||
} finally { cur.setBusy(false); btn.classList.remove('saving'); btn.disabled = false; }
|
||||
}
|
||||
|
||||
const IMG = /\.(png|jpe?g|gif|webp|bmp|ico|avif|svg)$/i, VID = /\.(mp4|m4v|webm|ogv|mov)$/i, AUD = /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i;
|
||||
const HEX_CAP = 64 * 1024 * 1024; // what the hex editor takes in memory
|
||||
const DOC = /\.(pdf|docx?|odt|xlsx|ods|csv|tsv|html?|db|sqlite3?|db3)$/i;
|
||||
const DOC_CAP = 32 * 1024 * 1024; // the biggest document drawn here
|
||||
|
||||
// Highlighting by the file's name, then its extension, then its #! line.
|
||||
const BY_EXT = {
|
||||
py:'python', pyw:'python', go:'go', c:'text/x-csrc', h:'text/x-csrc', cc:'text/x-c++src', cpp:'text/x-c++src', cxx:'text/x-c++src',
|
||||
hpp:'text/x-c++src', hh:'text/x-c++src', java:'text/x-java', kt:'text/x-kotlin', kts:'text/x-kotlin', cs:'text/x-csharp', scala:'text/x-scala',
|
||||
js:'javascript', mjs:'javascript', cjs:'javascript', jsx:'javascript', ts:'text/typescript', tsx:'text/typescript', json:'application/json',
|
||||
css:'css', scss:'text/x-scss', less:'text/x-less', html:'htmlmixed', htm:'htmlmixed', vue:'htmlmixed', xml:'xml', svg:'xml', plist:'xml', xsd:'xml',
|
||||
php:'php', sh:'shell', bash:'shell', zsh:'shell', ksh:'shell', yaml:'yaml', yml:'yaml', md:'markdown', markdown:'markdown', sql:'sql',
|
||||
toml:'toml', ini:'properties', cfg:'properties', conf:'properties', cnf:'properties', env:'properties', properties:'properties',
|
||||
service:'properties', timer:'properties', socket:'properties', mount:'properties', lua:'lua', rs:'rust', diff:'diff', patch:'diff',
|
||||
rb:'ruby', pl:'perl', pm:'perl', cmake:'cmake'
|
||||
};
|
||||
const BY_NAME = {
|
||||
'dockerfile':'dockerfile', 'containerfile':'dockerfile', 'makefile':'shell', '.bashrc':'shell', '.bash_profile':'shell', '.profile':'shell',
|
||||
'.zshrc':'shell', '.zprofile':'shell', '.env':'properties', 'cmakelists.txt':'cmake', 'gemfile':'ruby', 'rakefile':'ruby',
|
||||
'nginx.conf':'nginx', 'go.mod':'go', 'requirements.txt':'properties'
|
||||
};
|
||||
function modeFor(path, text) {
|
||||
const name = path.slice(path.lastIndexOf('/') + 1).toLowerCase();
|
||||
if (BY_NAME[name]) return BY_NAME[name];
|
||||
if (/^dockerfile\./.test(name)) return 'dockerfile';
|
||||
if (/\/nginx\//.test(path) && /\.conf$/.test(name)) return 'nginx';
|
||||
const dot = name.lastIndexOf('.');
|
||||
const ext = dot > 0 ? name.slice(dot + 1) : '';
|
||||
if (BY_EXT[ext]) return BY_EXT[ext];
|
||||
const bang = /^#!.*?\b(\w+)\s*$/.exec(text.slice(0, 200).split('\n')[0]);
|
||||
if (bang) return {sh:'shell', bash:'shell', zsh:'shell', dash:'shell', python:'python', python3:'python', node:'javascript', ruby:'ruby', perl:'perl'}[bang[1]] || null;
|
||||
return null;
|
||||
}
|
||||
const modeName = m => !m ? 'текст' : m.replace(/^(text|application)\/(x-)?/, '').replace('c++src', 'c++').replace('csrc', 'c');
|
||||
|
||||
let cm = null;
|
||||
function editor() {
|
||||
if (cm) return cm;
|
||||
cm = CodeMirror($('vcode'), {
|
||||
theme: 'lpra', lineNumbers: true, matchBrackets: true, indentUnit: 4, tabSize: 4,
|
||||
extraKeys: {
|
||||
'Ctrl-F': () => openFind(), 'Cmd-F': () => openFind(),
|
||||
'Ctrl-H': () => openFind(true), 'Cmd-Alt-F': () => openFind(true), 'Shift-Ctrl-F': () => openFind(true),
|
||||
'Ctrl-S': () => $('save').click(), 'Cmd-S': () => $('save').click(),
|
||||
'Ctrl-G': () => findStep(1), 'Cmd-G': () => findStep(1), 'Shift-Ctrl-G': () => findStep(-1), 'Shift-Cmd-G': () => findStep(-1),
|
||||
'Tab': c => c.somethingSelected() ? c.indentSelection('add') : c.replaceSelection(c.getOption('indentWithTabs') ? '\t' : ' '.repeat(c.getOption('indentUnit')))
|
||||
}
|
||||
});
|
||||
cm.on('changes', () => { V.dirty = !cm.isClean(V.clean); if (F.open) refreshFind(); });
|
||||
return cm;
|
||||
}
|
||||
|
||||
// What the viewer has open now.
|
||||
const V = { path: '', kind: '', dirty: false, clean: 0 };
|
||||
function showPane(which) {
|
||||
for (const [id, k] of [['vcode', 'code'], ['vmedia', 'media'], ['vhexed', 'hex'], ['vdoc', 'doc']]) $(id).hidden = which !== k;
|
||||
if (which !== 'doc') $('vdoc').innerHTML = '';
|
||||
if (which !== 'code') closeFind();
|
||||
}
|
||||
function setButtons(o) {
|
||||
$('vtext').hidden = !o.text; $('vhex').hidden = !o.hex; $('save').hidden = !o.save;
|
||||
}
|
||||
function openViewer(path) {
|
||||
V.path = path; V.dirty = false;
|
||||
$('vname').textContent = path;
|
||||
$('vname').parentElement.title = path;
|
||||
if (!$('viewer').open) openModal($('viewer'));
|
||||
}
|
||||
|
||||
async function view(e) {
|
||||
const path = join(cur.cwd, e.name);
|
||||
if (IMG.test(e.name) || VID.test(e.name) || AUD.test(e.name)) return viewMedia(path, e.size);
|
||||
if (DOC.test(e.name)) return viewDoc(path, e.size);
|
||||
const r = await api('read', {path});
|
||||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||||
if (r.data.binary) return viewHex(path, r.data.size);
|
||||
viewText(path, r.data);
|
||||
}
|
||||
|
||||
function viewText(path, d) {
|
||||
openViewer(path);
|
||||
V.kind = 'code';
|
||||
const text = new TextDecoder().decode(b64ToBytes(d.content));
|
||||
const editable = d.size <= EDIT_LIMIT && !d.truncated;
|
||||
const c = editor();
|
||||
showPane('code');
|
||||
const mode = modeFor(path, text);
|
||||
c.setOption('mode', mode);
|
||||
c.setOption('indentWithTabs', mode === 'go' || /(^|\/)makefile$/i.test(path));
|
||||
c.setOption('readOnly', !editable);
|
||||
c.setValue(text);
|
||||
c.clearHistory();
|
||||
V.clean = c.changeGeneration();
|
||||
V.dirty = false;
|
||||
setButtons({save: editable});
|
||||
$('frow2').hidden = $('frow2b').hidden = !editable;
|
||||
$('vmsg').textContent = fmtSize(d.size) + ' · ' + modeName(mode) +
|
||||
(d.truncated ? ' · показаны первые ' + fmtSize(EDIT_LIMIT) : '') + (editable ? '' : ' · только чтение (больше ' + fmtSize(EDIT_LIMIT) + ')') +
|
||||
' · Ctrl/Cmd+F - поиск и замена';
|
||||
$('save').onclick = async () => {
|
||||
const w = await putFile(path, new TextEncoder().encode(c.getValue()));
|
||||
if (w.ok) { V.clean = c.changeGeneration(); V.dirty = false; toast('сохранено'); cur.go(cur.cwd); } else toast(w.error || 'ошибка', true);
|
||||
};
|
||||
setTimeout(() => { c.refresh(); c.focus(); c.setCursor(0, 0); }, 0);
|
||||
}
|
||||
|
||||
function viewMedia(path, size) {
|
||||
openViewer(path);
|
||||
V.kind = 'media';
|
||||
showPane('media');
|
||||
const box = $('vmedia');
|
||||
box.innerHTML = '';
|
||||
const el = document.createElement(VID.test(path) ? 'video' : AUD.test(path) ? 'audio' : 'img');
|
||||
if (el.tagName !== 'IMG') { el.controls = true; el.preload = 'metadata'; }
|
||||
else el.alt = path;
|
||||
el.onerror = () => { $('vmsg').textContent = fmtSize(size) + ' · браузер не смог это показать'; };
|
||||
el.src = cur.src.url('download', path) + '&inline=1';
|
||||
box.appendChild(el);
|
||||
// SVG is text: edited as such, not byte by byte.
|
||||
const svg = /\.svg$/i.test(path);
|
||||
setButtons({text: svg, hex: !svg});
|
||||
$('vmsg').textContent = fmtSize(size);
|
||||
$('vtext').onclick = async () => {
|
||||
const r = await api('read', {path});
|
||||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||||
stopMedia();
|
||||
viewText(path, r.data);
|
||||
};
|
||||
$('vhex').onclick = () => { stopMedia(); viewHex(path, size); };
|
||||
}
|
||||
// PDF goes to the browser's own viewer; the rest is drawn by docview.js.
|
||||
async function viewDoc(path, size) {
|
||||
openViewer(path);
|
||||
V.kind = 'doc';
|
||||
showPane('doc');
|
||||
const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
|
||||
// Text with a preview gets the text editor; the rest, the hex one.
|
||||
const isText = ext === 'csv' || ext === 'tsv' || ext === 'html' || ext === 'htm';
|
||||
setButtons({text: isText, hex: !isText});
|
||||
$('vtext').onclick = async () => {
|
||||
const r = await api('read', {path});
|
||||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||||
viewText(path, r.data);
|
||||
};
|
||||
$('vhex').onclick = () => viewHex(path, size);
|
||||
const box = $('vdoc');
|
||||
$('vmsg').textContent = fmtSize(size);
|
||||
if (ext === 'pdf') {
|
||||
const src = cur.src.url('download', path) + '&inline=1';
|
||||
box.innerHTML = '<iframe class="dv-pdf" title="PDF"></iframe>';
|
||||
box.firstChild.src = src;
|
||||
const a = document.createElement('a');
|
||||
a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'открыть в новой вкладке';
|
||||
$('vmsg').append(' · ', a);
|
||||
return;
|
||||
}
|
||||
if (ext === 'html' || ext === 'htm') {
|
||||
// The page as it looks, in a frame that runs none of its scripts.
|
||||
const r = await api('read', {path});
|
||||
if (V.path !== path || V.kind !== 'doc') return;
|
||||
if (!r.ok) { box.innerHTML = '<p class="dv-note">не удалось показать: ' + esc(r.error || 'ошибка') + '</p>'; return; }
|
||||
const f = document.createElement('iframe');
|
||||
f.className = 'dv-html';
|
||||
f.setAttribute('sandbox', '');
|
||||
f.title = 'HTML';
|
||||
f.srcdoc = new TextDecoder().decode(b64ToBytes(r.data.content));
|
||||
box.innerHTML = '';
|
||||
box.appendChild(f);
|
||||
if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT);
|
||||
return;
|
||||
}
|
||||
if (/^(db|sqlite3?|db3)$/.test(ext)) {
|
||||
// The agent opens the database and edits it; one that predates that
|
||||
// gets the file read here, for viewing only.
|
||||
box.innerHTML = '<p class="dv-note">загрузка…</p>';
|
||||
const r = await DBEdit.open(box, path, (op, args) => api(op, args), toast);
|
||||
if (V.path !== path || V.kind !== 'doc') return;
|
||||
if (r && r.ok) return;
|
||||
if (!/неизвестная операция/.test((r && r.error) || '')) {
|
||||
box.innerHTML = '<p class="dv-note">не удалось открыть: ' + esc((r && r.error) || 'ошибка') + '</p>';
|
||||
return;
|
||||
}
|
||||
$('vmsg').append(' · только просмотр: обновите агента, чтобы править');
|
||||
}
|
||||
if (size > DOC_CAP) { box.innerHTML = '<p class="dv-note">файл больше ' + fmtSize(DOC_CAP) + ' - для просмотра скачайте его</p>'; return; }
|
||||
box.innerHTML = '<p class="dv-note">загрузка…</p>';
|
||||
let html;
|
||||
try {
|
||||
cur.setBusy(true);
|
||||
const r = await fetch(cur.src.url('download', path));
|
||||
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
|
||||
const buf = await r.arrayBuffer();
|
||||
if (V.path !== path || V.kind !== 'doc') return;
|
||||
html = ext === 'docx' ? await DocView.docx(buf)
|
||||
: ext === 'odt' ? await DocView.odt(buf)
|
||||
: ext === 'xlsx' ? await DocView.xlsx(buf)
|
||||
: ext === 'ods' ? await DocView.ods(buf)
|
||||
: ext === 'doc' ? DocView.doc(buf)
|
||||
: /^(db|sqlite3?|db3)$/.test(ext) ? DocView.sqlite(buf)
|
||||
: DocView.csv(new TextDecoder().decode(buf), ext === 'tsv');
|
||||
} catch (err) {
|
||||
html = '<p class="dv-note">не удалось показать: ' + esc(err.message) + '</p>';
|
||||
} finally { cur.setBusy(false); }
|
||||
if (V.path !== path || V.kind !== 'doc') return;
|
||||
box.innerHTML = html;
|
||||
DocView.bindTabs(box);
|
||||
}
|
||||
|
||||
function stopMedia() {
|
||||
const el = $('vmedia').firstChild;
|
||||
if (el && el.pause) el.pause();
|
||||
if (el) el.removeAttribute('src');
|
||||
$('vmedia').innerHTML = '';
|
||||
}
|
||||
|
||||
// ---------- Hex editor ----------
|
||||
// Overwrite in place: a hex digit sets half a byte, a character in the text
|
||||
// column sets a byte. Changed bytes stay marked until saved.
|
||||
const H = { data: null, orig: null, cur: 0, nib: 0, side: 'hex', changed: 0 };
|
||||
const ROW = 20;
|
||||
async function viewHex(path, size) {
|
||||
if (size > HEX_CAP) { toast('для бинарной правки файл слишком большой (больше ' + fmtSize(HEX_CAP) + ')', true); return; }
|
||||
openViewer(path);
|
||||
V.kind = 'hex';
|
||||
showPane('hex');
|
||||
setButtons({save: true});
|
||||
$('vmsg').textContent = 'загрузка…';
|
||||
$('hxrows').innerHTML = '';
|
||||
let buf;
|
||||
try {
|
||||
cur.setBusy(true);
|
||||
const r = await fetch(cur.src.url('download', path));
|
||||
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
|
||||
buf = new Uint8Array(await r.arrayBuffer());
|
||||
} catch (err) {
|
||||
$('vmsg').textContent = 'не удалось прочитать: ' + err.message;
|
||||
return;
|
||||
} finally { cur.setBusy(false); }
|
||||
if (V.path !== path || V.kind !== 'hex') return;
|
||||
H.data = buf; H.orig = buf.slice(); H.cur = 0; H.nib = 0; H.side = 'hex'; H.changed = 0;
|
||||
$('hxsp').style.height = (Math.ceil(buf.length / 16) * ROW + 8) + 'px';
|
||||
$('vhexed').scrollTop = 0;
|
||||
drawHex(); hexInfo();
|
||||
$('vhexed').focus();
|
||||
$('save').onclick = async () => {
|
||||
const w = await putFile(path, H.data);
|
||||
if (w.ok) { H.orig = H.data.slice(); H.changed = 0; V.dirty = false; drawHex(); hexInfo(); toast('сохранено'); cur.go(cur.cwd); }
|
||||
else toast(w.error || 'ошибка', true);
|
||||
};
|
||||
}
|
||||
const hx2 = b => (b < 16 ? '0' : '') + b.toString(16).toUpperCase();
|
||||
function drawHex() {
|
||||
const box = $('vhexed'), n = H.data.length;
|
||||
const first = Math.max(0, Math.floor(box.scrollTop / ROW) - 2);
|
||||
const last = Math.min(Math.ceil(n / 16), first + Math.ceil(box.clientHeight / ROW) + 5);
|
||||
let html = '';
|
||||
for (let r = first; r < last; r++) {
|
||||
let hb = '', ha = '';
|
||||
for (let i = r * 16; i < r * 16 + 16; i++) {
|
||||
if (i >= n) { hb += '<i> </i>'; continue; }
|
||||
const b = H.data[i], ch = b !== H.orig[i] ? 'ch' : '';
|
||||
const at = i === H.cur;
|
||||
hb += '<i data-i="' + i + '" class="' + ch + (at ? (H.side === 'hex' ? ' cur' : ' cur2') : '') + '">' + hx2(b) + '</i>';
|
||||
const c = b >= 32 && b < 127 ? String.fromCharCode(b) : '.';
|
||||
ha += '<i data-i="' + i + '" class="' + ch + (at ? (H.side === 'txt' ? ' cur' : ' cur2') : '') + '">' + (c === '<' ? '<' : c === '&' ? '&' : c) + '</i>';
|
||||
}
|
||||
html += '<div class="hx-r"><span class="hx-o">' + (r * 16).toString(16).toUpperCase().padStart(8, '0') +
|
||||
'</span><span class="hx-b">' + hb + '</span><span class="hx-a">' + ha + '</span></div>';
|
||||
}
|
||||
const rows = $('hxrows');
|
||||
rows.style.top = (first * ROW + 4) + 'px';
|
||||
rows.innerHTML = html;
|
||||
}
|
||||
function hexInfo() {
|
||||
$('vmsg').textContent = fmtSize(H.data.length) + ' · смещение 0x' + H.cur.toString(16).toUpperCase() + ' (' + H.cur + ')' +
|
||||
(H.changed ? ' · изменено байт: ' + H.changed : '') + ' · Tab - между hex и текстом';
|
||||
}
|
||||
function hexMove(to) {
|
||||
if (!H.data.length) return;
|
||||
H.cur = Math.max(0, Math.min(H.data.length - 1, to)); H.nib = 0;
|
||||
const box = $('vhexed'), y = Math.floor(H.cur / 16) * ROW;
|
||||
if (y < box.scrollTop) box.scrollTop = y;
|
||||
else if (y + ROW > box.scrollTop + box.clientHeight - 8) box.scrollTop = y + ROW - box.clientHeight + 8;
|
||||
drawHex(); hexInfo();
|
||||
}
|
||||
function hexSet(i, b) {
|
||||
const was = H.data[i] !== H.orig[i];
|
||||
H.data[i] = b;
|
||||
H.changed += (H.data[i] !== H.orig[i] ? 1 : 0) - (was ? 1 : 0);
|
||||
V.dirty = H.changed > 0;
|
||||
}
|
||||
$('vhexed').addEventListener('scroll', () => { if (V.kind === 'hex' && H.data) drawHex(); });
|
||||
$('vhexed').addEventListener('mousedown', ev => {
|
||||
const t = ev.target.closest('i[data-i]');
|
||||
if (!t) return;
|
||||
H.side = t.parentElement.classList.contains('hx-a') ? 'txt' : 'hex';
|
||||
hexMove(+t.dataset.i);
|
||||
});
|
||||
$('vhexed').addEventListener('keydown', ev => {
|
||||
if (!H.data || !H.data.length) return;
|
||||
const mod = ev.ctrlKey || ev.metaKey, page = 16 * Math.max(1, Math.floor($('vhexed').clientHeight / ROW) - 1);
|
||||
const moves = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -16, ArrowDown: 16, PageUp: -page, PageDown: page };
|
||||
if (mod && (ev.key === 's' || ev.key === 'S' || ev.code === 'KeyS')) { ev.preventDefault(); $('save').click(); return; }
|
||||
if (mod) return;
|
||||
if (moves[ev.key] !== undefined) { ev.preventDefault(); hexMove(H.cur + moves[ev.key]); return; }
|
||||
if (ev.key === 'Home') { ev.preventDefault(); hexMove(H.cur - H.cur % 16); return; }
|
||||
if (ev.key === 'End') { ev.preventDefault(); hexMove(H.cur - H.cur % 16 + 15); return; }
|
||||
if (ev.key === 'Tab') { ev.preventDefault(); H.side = H.side === 'hex' ? 'txt' : 'hex'; H.nib = 0; drawHex(); return; }
|
||||
if (ev.key.length !== 1) return;
|
||||
if (H.side === 'hex') {
|
||||
const h = parseInt(ev.key, 16);
|
||||
if (isNaN(h)) return;
|
||||
ev.preventDefault();
|
||||
const b = H.data[H.cur];
|
||||
if (H.nib === 0) { hexSet(H.cur, (h << 4) | (b & 15)); H.nib = 1; drawHex(); hexInfo(); }
|
||||
else { hexSet(H.cur, (b & 0xf0) | h); hexMove(H.cur + 1); }
|
||||
} else {
|
||||
const code = ev.key.charCodeAt(0);
|
||||
if (code < 32 || code > 126) return;
|
||||
ev.preventDefault();
|
||||
hexSet(H.cur, code); hexMove(H.cur + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Find and replace ----------
|
||||
const F = { open: false, overlay: null, caseS: false, re: false };
|
||||
function findRe(global) {
|
||||
const q = $('fq').value;
|
||||
if (!q) return null;
|
||||
try {
|
||||
return new RegExp(F.re ? q : q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), (global ? 'g' : '') + (F.caseS ? '' : 'i') + 'm');
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function openFind(replace) {
|
||||
if (V.kind !== 'code' || !cm) return;
|
||||
F.open = true;
|
||||
$('find').hidden = false;
|
||||
const sel = cm.getSelection();
|
||||
if (sel && sel.indexOf('\n') < 0) $('fq').value = sel;
|
||||
const input = replace && !$('frow2').hidden ? $('frep') : $('fq');
|
||||
input.focus();
|
||||
input.select();
|
||||
refreshFind();
|
||||
cm.refresh();
|
||||
}
|
||||
function closeFind() {
|
||||
if (!F.open) return;
|
||||
F.open = false;
|
||||
$('find').hidden = true;
|
||||
if (cm && F.overlay) { cm.removeOverlay(F.overlay); F.overlay = null; }
|
||||
if (cm) { cm.refresh(); if (V.kind === 'code') cm.focus(); }
|
||||
}
|
||||
// Marks every match and counts them; the one selected shows as "n из всех".
|
||||
function refreshFind() {
|
||||
if (F.overlay) { cm.removeOverlay(F.overlay); F.overlay = null; }
|
||||
const q = $('fq').value, re = findRe(true);
|
||||
$('fq').classList.toggle('bad', !!q && !re);
|
||||
if (!re) { $('fcnt').textContent = q ? 'ошибка' : ''; return; }
|
||||
F.overlay = { token: stream => {
|
||||
re.lastIndex = stream.pos;
|
||||
const m = re.exec(stream.string);
|
||||
if (m && m.index === stream.pos && m[0].length) { stream.pos += m[0].length; return 'searching'; }
|
||||
if (m && m.index > stream.pos) { stream.pos = m.index; return null; }
|
||||
if (m) { stream.next(); return null; }
|
||||
stream.skipToEnd(); return null;
|
||||
} };
|
||||
cm.addOverlay(F.overlay);
|
||||
countFind();
|
||||
}
|
||||
function countFind() {
|
||||
const re = findRe(false);
|
||||
if (!re) return;
|
||||
let n = 0, at = 0;
|
||||
const from = cm.getCursor('from'), to = cm.getCursor('to');
|
||||
const cur = cm.getSearchCursor(re, {line: 0, ch: 0});
|
||||
while (cur.findNext() && n < 9999) {
|
||||
if (CodeMirror.cmpPos(cur.from(), cur.to()) === 0) break;
|
||||
n++;
|
||||
if (!at && CodeMirror.cmpPos(cur.from(), from) === 0 && CodeMirror.cmpPos(cur.to(), to) === 0) at = n;
|
||||
}
|
||||
$('fcnt').textContent = n ? (at ? at + ' из ' + n : n + (n >= 9999 ? '+' : '')) : 'нет';
|
||||
}
|
||||
function findStep(dir, fromStart) {
|
||||
const re = findRe(false);
|
||||
if (!re || !cm) return false;
|
||||
let cur = cm.getSearchCursor(re, fromStart ? {line: 0, ch: 0} : dir > 0 ? cm.getCursor('to') : cm.getCursor('from'));
|
||||
let ok = dir > 0 ? cur.findNext() : cur.findPrevious();
|
||||
if (!ok) { // go round the end
|
||||
cur = cm.getSearchCursor(re, dir > 0 ? {line: 0, ch: 0} : {line: cm.lastLine(), ch: cm.getLine(cm.lastLine()).length});
|
||||
ok = dir > 0 ? cur.findNext() : cur.findPrevious();
|
||||
}
|
||||
if (!ok || CodeMirror.cmpPos(cur.from(), cur.to()) === 0) { countFind(); return false; }
|
||||
cm.setSelection(cur.from(), cur.to());
|
||||
cm.scrollIntoView({from: cur.from(), to: cur.to()}, 80);
|
||||
countFind();
|
||||
return true;
|
||||
}
|
||||
// $1.. and $& stand for the groups of a regular expression's match.
|
||||
function replacement(m) {
|
||||
const r = $('frep').value;
|
||||
return F.re && m ? r.replace(/\$(\d+|&)/g, (_, g) => g === '&' ? m[0] : (m[+g] || '')) : r;
|
||||
}
|
||||
function replaceOne() {
|
||||
const re = findRe(false);
|
||||
if (!re || cm.getOption('readOnly')) return;
|
||||
const from = cm.getCursor('from'), to = cm.getCursor('to');
|
||||
const cur = cm.getSearchCursor(re, from);
|
||||
const m = cur.findNext();
|
||||
if (m && CodeMirror.cmpPos(cur.from(), from) === 0 && CodeMirror.cmpPos(cur.to(), to) === 0) {
|
||||
cur.replace(replacement(m));
|
||||
cm.setCursor(cur.to());
|
||||
}
|
||||
findStep(1);
|
||||
}
|
||||
function replaceAll() {
|
||||
const re = findRe(false);
|
||||
if (!re || cm.getOption('readOnly')) return;
|
||||
let n = 0;
|
||||
cm.operation(() => {
|
||||
const cur = cm.getSearchCursor(re, {line: 0, ch: 0});
|
||||
let m;
|
||||
while ((m = cur.findNext()) && n < 100000) {
|
||||
if (CodeMirror.cmpPos(cur.from(), cur.to()) === 0) break;
|
||||
cur.replace(replacement(m));
|
||||
n++;
|
||||
}
|
||||
});
|
||||
toast(n ? 'заменено: ' + n : 'совпадений нет', !n);
|
||||
countFind();
|
||||
}
|
||||
$('fq').addEventListener('input', () => { refreshFind(); findStep(1, true); });
|
||||
$('fq').addEventListener('keydown', ev => {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); findStep(ev.shiftKey ? -1 : 1); }
|
||||
else if (ev.key === 'Escape') { ev.preventDefault(); closeFind(); }
|
||||
});
|
||||
$('frep').addEventListener('keydown', ev => {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); ev.ctrlKey || ev.metaKey ? replaceAll() : replaceOne(); }
|
||||
else if (ev.key === 'Escape') { ev.preventDefault(); closeFind(); }
|
||||
});
|
||||
$('fcase').onclick = () => { F.caseS = !F.caseS; $('fcase').classList.toggle('on', F.caseS); refreshFind(); };
|
||||
$('fre').onclick = () => { F.re = !F.re; $('fre').classList.toggle('on', F.re); refreshFind(); };
|
||||
$('fnext').onclick = () => findStep(1);
|
||||
$('fprev').onclick = () => findStep(-1);
|
||||
$('fclose').onclick = closeFind;
|
||||
$('frepone').onclick = replaceOne;
|
||||
$('frepall').onclick = replaceAll;
|
||||
// Ctrl/Cmd+F anywhere in the viewer opens this search, not the browser's.
|
||||
$('viewer').addEventListener('keydown', ev => {
|
||||
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'f' || ev.key === 'F' || ev.code === 'KeyF') && V.kind === 'code') {
|
||||
ev.preventDefault(); openFind();
|
||||
}
|
||||
});
|
||||
|
||||
// Closing: Esc first closes the search; unsaved changes are asked about.
|
||||
function closeViewer() {
|
||||
if (V.dirty && !confirm('Есть несохранённые изменения. Закрыть без сохранения?')) return;
|
||||
stopMedia(); closeFind();
|
||||
H.data = H.orig = null;
|
||||
V.kind = ''; V.dirty = false;
|
||||
$('viewer').close();
|
||||
}
|
||||
$('viewer').addEventListener('cancel', ev => { ev.preventDefault(); if (F.open) closeFind(); else closeViewer(); });
|
||||
$('closev').onclick = closeViewer;
|
||||
|
||||
// ---------- Properties ----------
|
||||
// Owner, group and mode of a file or folder; a folder's can go to all that
|
||||
// is in it too.
|
||||
const P = { path: '', orig: null };
|
||||
const modeStr = m => m.toString(8).padStart(m > 0o777 ? 4 : 3, '0');
|
||||
const permBoxes = () => [...document.querySelectorAll('#propdlg .pp-perm input[data-bit]')];
|
||||
function showMode(m) {
|
||||
permBoxes().forEach(b => b.checked = !!(m & +b.dataset.bit));
|
||||
$('pp-mode').value = modeStr(m);
|
||||
$('pp-mode').classList.remove('bad');
|
||||
}
|
||||
function readMode() {
|
||||
const v = $('pp-mode').value.trim();
|
||||
return /^[0-7]{3,4}$/.test(v) ? parseInt(v, 8) : null;
|
||||
}
|
||||
permBoxes().forEach(b => b.onchange = () => {
|
||||
let m = (readMode() ?? P.orig.mode) & 0o7000;
|
||||
permBoxes().forEach(x => { if (x.checked) m |= +x.dataset.bit; });
|
||||
showMode(m);
|
||||
});
|
||||
$('pp-mode').oninput = () => {
|
||||
const m = readMode();
|
||||
$('pp-mode').classList.toggle('bad', m === null);
|
||||
if (m !== null) permBoxes().forEach(b => b.checked = !!(m & +b.dataset.bit));
|
||||
};
|
||||
$('pp-recursive').onchange = () => $('pp-only').disabled = !$('pp-recursive').checked;
|
||||
|
||||
async function openProps(path) {
|
||||
const r = await api('stat', {path});
|
||||
if (!r.ok) {
|
||||
toast(/неизвестная операция/.test(r.error || '') ? 'свойства недоступны: обновите клиента' : (r.error || 'ошибка'), true);
|
||||
return;
|
||||
}
|
||||
const d = r.data;
|
||||
P.path = path; P.orig = d;
|
||||
$('pp-path').textContent = path;
|
||||
$('pp-info').textContent = (d.is_dir ? 'папка' : 'файл · ' + fmtSize(d.size)) + ' · изменён ' + fmtTime(d.mtime) +
|
||||
(d.link ? ' · ссылка на ' + d.link : '');
|
||||
$('pp-owner').value = d.owner; $('pp-group').value = d.group;
|
||||
$('pp-users').innerHTML = (d.users || []).map(u => '<option value="' + esc(u) + '">').join('');
|
||||
$('pp-groups').innerHTML = (d.groups || []).map(g => '<option value="' + esc(g) + '">').join('');
|
||||
showMode(d.mode);
|
||||
$('pp-rec').hidden = !d.is_dir;
|
||||
$('pp-recursive').checked = false; $('pp-only').value = ''; $('pp-only').disabled = true;
|
||||
if (!$('propdlg').open) openModal($('propdlg'));
|
||||
}
|
||||
$('pp-apply').onclick = async () => {
|
||||
const o = P.orig, mode = readMode();
|
||||
if (mode === null) { toast('права - три или четыре восьмеричные цифры, например 755', true); return; }
|
||||
const rec = o.is_dir && $('pp-recursive').checked;
|
||||
const owner = $('pp-owner').value.trim(), group = $('pp-group').value.trim();
|
||||
const args = {path: P.path};
|
||||
if (mode !== o.mode || rec) args.mode = mode;
|
||||
if (owner && (owner !== o.owner || rec)) args.owner = owner;
|
||||
if (group && (group !== o.group || rec)) args.group = group;
|
||||
if (rec) { args.recursive = true; args.only = $('pp-only').value; }
|
||||
if (Object.keys(args).length === 1) { toast('ничего не изменено'); return; }
|
||||
if (rec && !confirm('Применить к «' + P.path + '» и всему, что внутри?')) return;
|
||||
const btn = $('pp-apply');
|
||||
btn.disabled = true;
|
||||
const r = await api('chmod', args);
|
||||
btn.disabled = false;
|
||||
if (!r.ok) { toast(/неизвестная операция/.test(r.error || '') ? 'обновите клиента' : (r.error || 'ошибка'), true); return; }
|
||||
const c = r.data || {};
|
||||
toast(rec ? 'применено: ' + c.changed + (c.failed ? ', не удалось: ' + c.failed + ' (' + (c.errors || [])[0] + ')' : '') : 'применено', !!c.failed);
|
||||
openProps(P.path);
|
||||
cur.go(cur.cwd);
|
||||
};
|
||||
$('propdlg-close').onclick = () => $('propdlg').close();
|
||||
$('vprops').onclick = () => { if (V.path) openProps(V.path); };
|
||||
|
||||
async function del(e) {
|
||||
if (e.is_dir) {
|
||||
if (!confirm('Рекурсивно удалить каталог "'+e.name+'" со всем содержимым?')) return;
|
||||
const r = await api('deltree', {path: join(cur.cwd, e.name)});
|
||||
r.ok ? (toast('удалено'), cur.go(cur.cwd)) : toast(r.error||'ошибка', true);
|
||||
} else {
|
||||
if (!confirm('Удалить файл "'+e.name+'"?')) return;
|
||||
const r = await api('delete', {path: join(cur.cwd, e.name)});
|
||||
r.ok ? (toast('удалено'), cur.go(cur.cwd)) : toast(r.error||'ошибка', true);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Move or copy ----------
|
||||
// remote: to another machine (to: its id) or an SSH host (to: 'ssh').
|
||||
const M = { src: '', op: 'move', sug: [], sel: -1, seq: 0, remote: false, to: '', auth: 'password', job: null };
|
||||
const dirCache = new Map(); // machine + folder -> {at, names} of its subfolders
|
||||
// The machine the folder is on: this one, or the one it goes to.
|
||||
const target = () => !M.remote ? cur.agent.id : M.to === 'ssh' ? '' : M.to;
|
||||
// Lists a folder for the suggestions, quietly: no spinner, kept a little.
|
||||
async function subdirs(path) {
|
||||
const on = target();
|
||||
if (!on) return [];
|
||||
const key = on + '\n' + path;
|
||||
const c = dirCache.get(key);
|
||||
if (c && Date.now() - c.at < 30000) return c.names;
|
||||
try {
|
||||
const r = await fetch('/api/panel/agents/' + encodeURIComponent(on) + '/fs/list', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({path})
|
||||
}).then(r => r.json());
|
||||
const names = r.ok ? (r.data.entries || []).filter(e => e.is_dir).map(e => e.name).sort(byNameStr) : [];
|
||||
dirCache.set(key, {at: Date.now(), names});
|
||||
return names;
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
const byNameStr = (a, b) => a.localeCompare(b, undefined, {numeric: true, sensitivity: 'base'});
|
||||
function setOp(op) {
|
||||
M.op = op;
|
||||
document.querySelectorAll('#mv-op button').forEach(b => { b.classList.toggle('on', b.dataset.op === op); b.setAttribute('aria-checked', b.dataset.op === op); });
|
||||
goLabel();
|
||||
}
|
||||
function goLabel() {
|
||||
$('mv-go').textContent = (M.op === 'move' ? 'Переместить' : 'Копировать') + (M.remote ? ' туда' : '');
|
||||
}
|
||||
// ---- to another machine, or by SSH ----
|
||||
function lastShip() { try { return JSON.parse(localStorage.getItem('lpra.ship.last') || '{}'); } catch (e) { return {}; } }
|
||||
function setRemote(on) {
|
||||
M.remote = on;
|
||||
$('mv-net').classList.toggle('on', on);
|
||||
$('mv-net').setAttribute('aria-pressed', on);
|
||||
$('mv-remote').classList.toggle('on', on);
|
||||
goLabel();
|
||||
if (on) loadTargets();
|
||||
$('mv-sug').hidden = true;
|
||||
}
|
||||
function setTo(v) {
|
||||
M.to = v;
|
||||
$('mv-ssh').hidden = v !== 'ssh';
|
||||
dirCache.clear();
|
||||
}
|
||||
function setAuth(a) {
|
||||
M.auth = a;
|
||||
document.querySelectorAll('#mv-auth button').forEach(b => b.classList.toggle('on', b.dataset.auth === a));
|
||||
document.querySelectorAll('.mv-a').forEach(el => { el.hidden = el.dataset.for !== a; });
|
||||
}
|
||||
async function loadTargets() {
|
||||
const sel = $('mv-to'), last = lastShip();
|
||||
sel.innerHTML = '<option value="" disabled>загрузка…</option>';
|
||||
let all = [];
|
||||
try { all = await fetch('/api/panel/agents/presence', {cache: 'no-store'}).then(r => r.json()); } catch (e) { /* SSH only */ }
|
||||
sel.innerHTML = '';
|
||||
all.filter(a => a.id !== cur.agent.id).forEach(a => {
|
||||
const o = document.createElement('option');
|
||||
o.value = a.id; o.disabled = !a.online;
|
||||
o.textContent = a.name + ' · ' + a.user + '@' + a.hostname + (a.online ? '' : ' (не в сети)');
|
||||
sel.appendChild(o);
|
||||
});
|
||||
const o = document.createElement('option');
|
||||
o.value = 'ssh'; o.textContent = 'SSH-хост…';
|
||||
sel.appendChild(o);
|
||||
const ok = v => [...sel.options].some(x => x.value === v && !x.disabled);
|
||||
const first = [...sel.options].find(x => !x.disabled);
|
||||
sel.value = ok(last.to) ? last.to : first ? first.value : 'ssh';
|
||||
setTo(sel.value);
|
||||
if (last.ssh) { $('mv-host').value = last.ssh.host || ''; $('mv-port').value = last.ssh.port || 22; $('mv-user').value = last.ssh.user || ''; setAuth(last.ssh.auth || 'password'); }
|
||||
}
|
||||
$('mv-net').onclick = () => { if (!M.job) setRemote(!M.remote); };
|
||||
$('mv-to').onchange = ev => { setTo(ev.target.value); suggest(); };
|
||||
document.querySelectorAll('#mv-auth button').forEach(b => b.onclick = () => setAuth(b.dataset.auth));
|
||||
$('mv-keyload').onclick = () => $('mv-keyfile').click();
|
||||
$('mv-keyfile').onchange = () => {
|
||||
const f = $('mv-keyfile').files[0];
|
||||
if (!f) return;
|
||||
if (f.size > 32768) { toast('файл великоват для ключа', true); return; }
|
||||
f.text().then(t => { $('mv-key').value = t.trim() + '\n'; $('mv-keyname').textContent = f.name; });
|
||||
$('mv-keyfile').value = '';
|
||||
};
|
||||
function sshTarget() {
|
||||
const t = {host: $('mv-host').value.trim(), port: Number($('mv-port').value) || 22, user: $('mv-user').value.trim()};
|
||||
if (!t.host || !t.user) return {error: 'укажите хост и пользователя'};
|
||||
if (M.auth === 'password') {
|
||||
if (!$('mv-pw').value) return {error: 'введите пароль'};
|
||||
t.password = $('mv-pw').value;
|
||||
} else if (M.auth === 'key') {
|
||||
if (!/PRIVATE KEY/.test($('mv-key').value)) return {error: 'вставьте закрытый ключ или загрузите файл'};
|
||||
t.key = $('mv-key').value;
|
||||
if ($('mv-pass').value) t.passphrase = $('mv-pass').value;
|
||||
}
|
||||
return {t};
|
||||
}
|
||||
function busy(on) {
|
||||
document.querySelector('#mvdlg .mv').classList.toggle('running', on);
|
||||
$('mv-go').disabled = on;
|
||||
$('mv-prog').hidden = !on;
|
||||
if (on) requestAnimationFrame(() => $('mv-prog').scrollIntoView({block: 'nearest', behavior: 'smooth'}));
|
||||
}
|
||||
// ship sends to the other machine and follows the job to its end.
|
||||
async function ship(dir, name, conflict, move) {
|
||||
const body = {src: M.src, name, dir, conflict, move};
|
||||
const keep = {to: M.to};
|
||||
if (M.to === 'ssh') {
|
||||
const r = sshTarget();
|
||||
if (r.error) { toast(r.error, true); return; }
|
||||
body.ssh = r.t;
|
||||
keep.ssh = {host: r.t.host, port: r.t.port, user: r.t.user, auth: M.auth};
|
||||
} else {
|
||||
if (!M.to) { toast('выберите машину', true); return; }
|
||||
body.to = M.to;
|
||||
keep.ssh = lastShip().ssh;
|
||||
}
|
||||
try { localStorage.setItem('lpra.ship.last', JSON.stringify(keep)); } catch (e) { /* not kept */ }
|
||||
busy(true);
|
||||
$('mv-bar').parentNode.classList.add('wait');
|
||||
$('mv-bar').style.width = '0';
|
||||
$('mv-ptext').textContent = 'подготовка…';
|
||||
const r = await api('ship', body);
|
||||
$('mv-pw').value = ''; $('mv-pass').value = '';
|
||||
if (!r.ok) { busy(false); toast(r.error || 'ошибка', true); return; }
|
||||
const job = M.job = r.data.job;
|
||||
let v = 0, res = null;
|
||||
const t0 = Date.now(), seen = [];
|
||||
while (!res) {
|
||||
let j;
|
||||
try { j = await fetch('/api/panel/fs/job/' + job + '?v=' + v, {cache: 'no-store'}).then(x => x.json()); }
|
||||
catch (e) { await new Promise(ok => setTimeout(ok, 1500)); continue; }
|
||||
v = j.v || 0;
|
||||
const p = j.progress || {};
|
||||
if (p.total > 0 || p.done > 0) {
|
||||
$('mv-bar').parentNode.classList.remove('wait');
|
||||
$('mv-bar').style.width = (p.total ? Math.min(100, p.done / p.total * 100) : 0).toFixed(1) + '%';
|
||||
// Speed over the last few seconds.
|
||||
seen.push([Date.now(), p.done]);
|
||||
while (seen.length > 2 && Date.now() - seen[0][0] > 4000) seen.shift();
|
||||
const dt = (seen[seen.length - 1][0] - seen[0][0]) / 1000, rate = dt > 0.5 ? (seen[seen.length - 1][1] - seen[0][1]) / dt : 0;
|
||||
$('mv-ptext').textContent = fmtSize(p.done) + ' из ' + fmtSize(p.total) + ' · файлов: ' + p.files + (rate ? ' · ' + fmtSize(Math.round(rate)) + '/с' : '');
|
||||
} else if (p.note) {
|
||||
$('mv-ptext').textContent = p.note === 'подсчёт' ? 'подсчёт…' : p.note + '…';
|
||||
}
|
||||
if (j.done) res = j.result;
|
||||
}
|
||||
M.job = null;
|
||||
if (!res.ok) { busy(false); toast(res.error || 'ошибка', true); return; }
|
||||
$('mv-bar').style.width = '100%';
|
||||
const d = res.data || {}, secs = Math.max(1, Math.round((Date.now() - t0) / 1000));
|
||||
const what = move ? 'перенесено' : 'отправлено';
|
||||
toast(what + (d.done != null ? ': ' + d.done : d.files != null ? ': ' + d.files : '') + (d.skipped ? ', оставлено как было: ' + d.skipped : '') + ' · ' + secs + ' с');
|
||||
setTimeout(() => { busy(false); if ($('mvdlg').open) $('mvdlg').close(); }, 400);
|
||||
if (move) cur.go(cur.cwd);
|
||||
}
|
||||
$('mv-stop').onclick = () => {
|
||||
if (M.job) fetch('/api/panel/fs/job/' + M.job + '/cancel', {method: 'POST'});
|
||||
$('mv-ptext').textContent = 'отмена…';
|
||||
};
|
||||
document.querySelectorAll('#mv-op button').forEach(b => b.onclick = () => setOp(b.dataset.op));
|
||||
// Suggestions: the subfolders of what is typed, matching its last part.
|
||||
async function suggest() {
|
||||
const v = $('mv-dir').value, seq = ++M.seq;
|
||||
const i = v.lastIndexOf('/');
|
||||
const parent = i <= 0 ? '/' : v.slice(0, i), pre = v.slice(i + 1).toLowerCase();
|
||||
const names = await subdirs(parent);
|
||||
if (seq !== M.seq) return;
|
||||
M.sug = names.filter(n => n.toLowerCase().startsWith(pre)).slice(0, 60)
|
||||
.map(n => (parent === '/' ? '' : parent) + '/' + n + '/');
|
||||
M.sel = -1;
|
||||
showSug();
|
||||
}
|
||||
function showSug() {
|
||||
const ul = $('mv-sug');
|
||||
ul.innerHTML = '';
|
||||
M.sug.forEach((full, k) => {
|
||||
const li = document.createElement('li');
|
||||
const cut = full.slice(0, -1).lastIndexOf('/') + 1;
|
||||
li.innerHTML = '<span class="muted"></span><span></span>';
|
||||
li.firstChild.textContent = full.slice(0, cut);
|
||||
li.lastChild.textContent = full.slice(cut);
|
||||
if (k === M.sel) li.className = 'sel';
|
||||
li.onmousedown = ev => { ev.preventDefault(); pick(k); };
|
||||
ul.appendChild(li);
|
||||
});
|
||||
ul.hidden = !M.sug.length || document.activeElement !== $('mv-dir');
|
||||
const sel = ul.children[M.sel];
|
||||
if (sel) sel.scrollIntoView({block: 'nearest'});
|
||||
}
|
||||
function pick(k) {
|
||||
$('mv-dir').value = M.sug[k];
|
||||
suggest();
|
||||
}
|
||||
let sugTimer = null;
|
||||
$('mv-dir').addEventListener('input', () => { clearTimeout(sugTimer); sugTimer = setTimeout(suggest, 120); });
|
||||
$('mv-dir').addEventListener('focus', suggest);
|
||||
$('mv-dir').addEventListener('blur', () => setTimeout(() => { $('mv-sug').hidden = true; }, 100));
|
||||
$('mv-dir').addEventListener('keydown', ev => {
|
||||
const open = !$('mv-sug').hidden && M.sug.length;
|
||||
if (ev.key === 'ArrowDown' && open) { ev.preventDefault(); M.sel = (M.sel + 1) % M.sug.length; showSug(); }
|
||||
else if (ev.key === 'ArrowUp' && open) { ev.preventDefault(); M.sel = (M.sel - 1 + M.sug.length) % M.sug.length; showSug(); }
|
||||
else if ((ev.key === 'Tab' || ev.key === 'Enter') && open && (M.sel >= 0 || ev.key === 'Tab')) { ev.preventDefault(); pick(Math.max(0, M.sel)); }
|
||||
else if (ev.key === 'Enter') { ev.preventDefault(); $('mv-go').click(); }
|
||||
else if (ev.key === 'Escape' && open) { ev.preventDefault(); ev.stopPropagation(); $('mv-sug').hidden = true; }
|
||||
});
|
||||
$('mv-name').addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); $('mv-go').click(); } });
|
||||
|
||||
function move(e) {
|
||||
M.src = join(cur.cwd, e.name);
|
||||
$('mv-src').textContent = M.src + (e.is_dir ? '/' : '');
|
||||
setOp('move');
|
||||
if (!M.job) { busy(false); setRemote(false); }
|
||||
$('mv-dir').value = cur.cwd === '/' ? '/' : cur.cwd + '/';
|
||||
$('mv-name').value = e.name;
|
||||
$('mv-conflict').value = 'error';
|
||||
dirCache.clear();
|
||||
openModal($('mvdlg'));
|
||||
$('mv-dir').focus();
|
||||
const end = $('mv-dir').value.length;
|
||||
$('mv-dir').setSelectionRange(end, end);
|
||||
}
|
||||
$('mv-go').onclick = async () => {
|
||||
const dir = $('mv-dir').value.trim().replace(/\/+$/, '') || '/';
|
||||
const name = $('mv-name').value.trim();
|
||||
if (!name || name.includes('/')) { toast('имя - без «/»', true); return; }
|
||||
const dst = (dir === '/' ? '' : dir) + '/' + name;
|
||||
const conflict = $('mv-conflict').value, move = M.op === 'move';
|
||||
if (M.remote) { if (!M.job) ship(dir, name, conflict, move); return; }
|
||||
const btn = $('mv-go');
|
||||
btn.disabled = true;
|
||||
let r = await api('copy', {src: M.src, dst, move, conflict});
|
||||
// A client that predates copying still moves, when nothing is in the way.
|
||||
if (!r.ok && /неизвестная операция/.test(r.error || '')) {
|
||||
r = move && conflict === 'error' ? await api('move', {src: M.src, dst}) : {ok: false, error: 'копирование и замена: обновите клиента'};
|
||||
}
|
||||
btn.disabled = false;
|
||||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||||
const skipped = r.data && r.data.skipped;
|
||||
toast((move ? 'перемещено' : 'скопировано') + (skipped ? ', оставлено как было: ' + skipped : ''));
|
||||
$('mvdlg').close();
|
||||
cur.go(cur.cwd);
|
||||
};
|
||||
$('mvdlg-close').onclick = () => $('mvdlg').close();
|
||||
|
||||
|
||||
// The list of a pane's uploads, opened from its progress figures.
|
||||
let upPane = null;
|
||||
function renderUploadList() {
|
||||
const list = $('updlg-list');
|
||||
if (!$('updlg').open || !upPane) return;
|
||||
const uploads = upPane.uploads;
|
||||
list.innerHTML = uploads.length ? '' : '<p class="muted">Сейчас ничего не загружается.</p>';
|
||||
const UP_ST = { wait: 'в очереди', send: '', write: 'запись на клиенте…', done: 'готово', fail: 'ошибка' };
|
||||
for (const u of uploads) {
|
||||
const pct = u.state === 'send' ? Math.floor(100 * u.frac) : u.state === 'wait' ? 0 : 100;
|
||||
const st = u.state === 'send' ? pct + '%' : u.state === 'fail' ? (u.error || UP_ST.fail) : UP_ST[u.state];
|
||||
const el = document.createElement('div');
|
||||
el.className = 'upl-item';
|
||||
el.innerHTML = '<div class="upl-row"><span class="n"></span><span class="st"></span></div><div class="upl-bar"><i></i></div>';
|
||||
el.querySelector('.n').textContent = u.name + ' · ' + fmtSize(u.size);
|
||||
const stEl = el.querySelector('.st');
|
||||
stEl.textContent = st;
|
||||
stEl.className = 'st' + (u.state === 'done' ? ' ok' : u.state === 'fail' ? ' bad' : '');
|
||||
el.querySelector('.upl-bar i').style.transform = 'scaleX(' + pct / 100 + ')';
|
||||
if (u.state === 'fail') el.querySelector('.upl-bar i').style.background = 'var(--danger)';
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
$('updlg-close').onclick = () => $('updlg').close();
|
||||
|
||||
const use = (pane, fn) => (...a) => { cur = pane; return fn(...a); };
|
||||
window.FileViews = {
|
||||
view: (pane, e) => use(pane, view)(e),
|
||||
props: (pane, path) => use(pane, openProps)(path),
|
||||
move: (pane, e) => use(pane, move)(e),
|
||||
del: (pane, e) => use(pane, del)(e),
|
||||
uploads(pane) { upPane = pane; openModal($('updlg')); renderUploadList(); },
|
||||
uploadsChanged(pane) { if (pane === upPane) renderUploadList(); },
|
||||
sendFile, toast, openModal, join, parent, fmtSize, fmtTime, esc, b64ToBytes
|
||||
};
|
||||
})();
|
||||
@@ -305,7 +305,7 @@
|
||||
const src = drag.kind === 'tab' ? drag.obj.el : drag.obj.el.querySelector('.pane-head');
|
||||
const g = document.createElement('div');
|
||||
g.className = 'drag-ghost ' + drag.kind;
|
||||
g.textContent = drag.kind === 'tab' ? tabTitle(drag.obj) : drag.obj.agent.user + '@' + drag.obj.agent.host;
|
||||
g.textContent = drag.kind === 'tab' ? tabTitle(drag.obj) : (drag.obj.tooltip ? drag.obj.tooltip() : drag.obj.title());
|
||||
document.body.appendChild(g);
|
||||
drag.ghost = g;
|
||||
drag.src = src;
|
||||
@@ -409,7 +409,7 @@
|
||||
renderTab(t);
|
||||
layout(t, true);
|
||||
updateMode();
|
||||
s.term.focus();
|
||||
s.focus();
|
||||
} else if (d.kind === 'pane' && tg.kind === 'strip') {
|
||||
const s = d.obj, from = tabOf(s), ref = tg.others[tg.index];
|
||||
takePane(from, s);
|
||||
|
||||
@@ -54,13 +54,154 @@
|
||||
</section>
|
||||
<section class="view" data-view="settings"{{if ne .View "settings"}} hidden{{end}}>{{if eq .View "settings"}}{{.Body}}{{end}}</section>
|
||||
</main>
|
||||
<script>window.APP = {admin: {{.IsAdmin}}, open: {{.Open}}, dialog: {{.Dialog}}};</script>
|
||||
<dialog id="viewer" tabindex="-1">
|
||||
<div class="dlg-h">
|
||||
<strong class="vpath" dir="rtl"><bdi id="vname"></bdi></strong>
|
||||
<button class="btn ghost small" id="vtext" hidden>Редактировать</button>
|
||||
<button class="btn ghost small" id="vhex" hidden>Редактировать бинарно</button>
|
||||
<button class="btn small" id="save" hidden>Сохранить</button>
|
||||
<button class="btn ghost small icon sq" id="vprops" 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>
|
||||
<button class="btn ghost small icon close x" id="closev" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
<div class="vmsg muted" id="vmsg"></div>
|
||||
</div>
|
||||
<div class="vbody">
|
||||
<div class="find" id="find" hidden>
|
||||
<div class="fin"><input type="text" id="fq" placeholder="Найти" spellcheck="false" autocomplete="off"><span class="cnt" id="fcnt"></span></div>
|
||||
<div class="fbtns">
|
||||
<button type="button" class="tg" id="fcase" title="Учитывать регистр">Aa</button>
|
||||
<button type="button" class="tg" id="fre" title="Регулярное выражение">.*</button>
|
||||
<button type="button" class="btn ghost" id="fprev" title="Предыдущее (Shift+Enter)" aria-label="Предыдущее">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 2.5 11 9.5H1z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" id="fnext" title="Следующее (Enter)" aria-label="Следующее">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 9.5 1 2.5h10z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost close" id="fclose" title="Закрыть поиск (Esc)" aria-label="Закрыть поиск">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="fin" id="frow2"><input type="text" id="frep" placeholder="Заменить на" spellcheck="false" autocomplete="off"></div>
|
||||
<div class="fbtns" id="frow2b">
|
||||
<button type="button" class="btn ghost" id="frepone">Заменить</button>
|
||||
<button type="button" class="btn ghost" id="frepall">Все</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vcode" hidden></div>
|
||||
<div class="vmedia" id="vmedia" hidden></div>
|
||||
<div class="vdoc" id="vdoc" hidden></div>
|
||||
<div class="hexed" id="vhexed" tabindex="0" hidden><div id="hxsp"></div><div class="hx-rows" id="hxrows"></div></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="updlg" tabindex="-1">
|
||||
<div class="dlg-h">
|
||||
<strong style="flex:1">Загрузка файлов</strong>
|
||||
<button class="btn ghost small icon close x" id="updlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
<div class="dlg-b" id="updlg-list"></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mvdlg" tabindex="-1">
|
||||
<div class="dlg-h">
|
||||
<strong style="flex:1">Переместить или копировать</strong>
|
||||
<button class="btn ghost small icon close x" id="mvdlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
<div class="dlg-b mv">
|
||||
<div class="pp-path" id="mv-src"></div>
|
||||
<div class="mv-top">
|
||||
<div class="seg" id="mv-op" role="radiogroup">
|
||||
<button type="button" data-op="move" role="radio">Переместить</button>
|
||||
<button type="button" data-op="copy" role="radio">Копировать</button>
|
||||
</div>
|
||||
<button type="button" class="btn ghost sq mv-net" id="mv-net" title="На другую машину или по SSH" aria-label="На другую машину или по SSH" aria-pressed="false"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/><path d="M12 8v4M5 16v-2a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v2"/></svg></button>
|
||||
</div>
|
||||
<div class="mv-remote" id="mv-remote"><div>
|
||||
<label for="mv-to">На машину</label>
|
||||
<select id="mv-to"></select>
|
||||
<div class="mv-ssh" id="mv-ssh" hidden>
|
||||
<div class="mv-row"><label class="grow">Хост<input type="text" id="mv-host" placeholder="example.com" autocomplete="off" spellcheck="false" autocapitalize="off"></label>
|
||||
<label class="port">Порт<input type="number" id="mv-port" min="1" max="65535" value="22"></label></div>
|
||||
<label>Пользователь<input type="text" id="mv-user" placeholder="root" autocomplete="off" spellcheck="false" autocapitalize="off"></label>
|
||||
<div class="seg seg-auth" id="mv-auth">
|
||||
<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button>
|
||||
</div>
|
||||
<div class="mv-a" data-for="password"><input type="password" id="mv-pw" placeholder="пароль" autocomplete="off"></div>
|
||||
<div class="mv-a" data-for="key" hidden>
|
||||
<textarea id="mv-key" rows="4" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- …вставьте ключ или загрузите файл"></textarea>
|
||||
<div class="mv-row"><button type="button" class="btn ghost small" id="mv-keyload">Загрузить файл…</button><span class="muted" id="mv-keyname"></span><input type="file" id="mv-keyfile" hidden></div>
|
||||
<input type="password" id="mv-pass" placeholder="фраза ключа, если есть" autocomplete="off">
|
||||
</div>
|
||||
<p class="mv-a muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh без фразы.</p>
|
||||
</div>
|
||||
</div></div>
|
||||
<label for="mv-dir">Куда (папка)</label>
|
||||
<div class="mv-dirbox">
|
||||
<input type="text" id="mv-dir" autocomplete="off" spellcheck="false" autocapitalize="off">
|
||||
<ul class="mv-sug" id="mv-sug" hidden></ul>
|
||||
</div>
|
||||
<label for="mv-name">Имя</label>
|
||||
<input type="text" id="mv-name" autocomplete="off" spellcheck="false" autocapitalize="off">
|
||||
<label for="mv-conflict">Если там уже есть такое</label>
|
||||
<select id="mv-conflict">
|
||||
<option value="error">ничего не трогать и сообщить</option>
|
||||
<option value="replace">заменить (папки слить, файлы заменить)</option>
|
||||
<option value="skip">оставить существующее (папки слить)</option>
|
||||
</select>
|
||||
<div class="mv-prog" id="mv-prog" hidden>
|
||||
<div class="mv-bar"><i id="mv-bar"></i></div>
|
||||
<div class="mv-ptext"><span class="mv-pwrap"><span class="spinner"></span><span id="mv-ptext">подготовка…</span></span><button type="button" class="btn ghost small" id="mv-stop">Отменить</button></div>
|
||||
</div>
|
||||
<div class="pp-act"><button type="button" id="mv-go">Переместить</button></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="propdlg" tabindex="-1">
|
||||
<div class="dlg-h">
|
||||
<strong style="flex:1">Свойства</strong>
|
||||
<button class="btn ghost small icon close x" id="propdlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
<div class="dlg-b props">
|
||||
<div class="pp-path" id="pp-path"></div>
|
||||
<div class="pp-info muted" id="pp-info"></div>
|
||||
<div class="pp-own">
|
||||
<label>Владелец<input type="text" id="pp-owner" list="pp-users" autocomplete="off" spellcheck="false"></label>
|
||||
<label>Группа<input type="text" id="pp-group" list="pp-groups" autocomplete="off" spellcheck="false"></label>
|
||||
</div>
|
||||
<datalist id="pp-users"></datalist><datalist id="pp-groups"></datalist>
|
||||
<table class="pp-perm">
|
||||
<thead><tr><th></th><th>Чтение</th><th>Запись</th><th>Выполнение</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><th>Владелец</th><td><input type="checkbox" data-bit="256"></td><td><input type="checkbox" data-bit="128"></td><td><input type="checkbox" data-bit="64"></td></tr>
|
||||
<tr><th>Группа</th><td><input type="checkbox" data-bit="32"></td><td><input type="checkbox" data-bit="16"></td><td><input type="checkbox" data-bit="8"></td></tr>
|
||||
<tr><th>Остальные</th><td><input type="checkbox" data-bit="4"></td><td><input type="checkbox" data-bit="2"></td><td><input type="checkbox" data-bit="1"></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<label class="pp-num">Числовое значение <input type="text" id="pp-mode" maxlength="4" inputmode="numeric" autocomplete="off"></label>
|
||||
<div class="pp-rec" id="pp-rec" hidden>
|
||||
<label class="check"><input type="checkbox" id="pp-recursive"> Применить ко всем вложенным</label>
|
||||
<select id="pp-only" disabled>
|
||||
<option value="">к файлам и папкам</option>
|
||||
<option value="files">только к файлам</option>
|
||||
<option value="dirs">только к папкам</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="pp-act"><button type="button" id="pp-apply">Применить</button></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script>window.APP = {admin: {{.IsAdmin}}, open: {{.Open}}, dialog: {{.Dialog}}, editLimit: {{.EditLimit}}};</script>
|
||||
<script src="{{asset "wsutil.js"}}"></script>
|
||||
<script src="{{asset "xterm.js"}}"></script>
|
||||
<script src="{{asset "addon-fit.js"}}"></script>
|
||||
<script src="{{asset "app.js"}}"></script>
|
||||
<script src="{{asset "workspace.js"}}"></script>
|
||||
<script src="{{asset "termpane.js"}}"></script>
|
||||
<script src="{{asset "codemirror/codemirror.js"}}"></script>
|
||||
<script src="{{asset "docview.js"}}"></script>
|
||||
<script src="{{asset "dbedit.js"}}"></script>
|
||||
<script src="{{asset "fileviews.js"}}"></script>
|
||||
<script src="{{asset "filepane.js"}}"></script>
|
||||
<script src="{{asset "pages.js"}}"></script>
|
||||
<script src="{{asset "users.js"}}"></script>
|
||||
<script src="{{asset "account.js"}}"></script>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user