feat: file and folder properties - mode, owner and group, applied to a folder's contents too (0.0.8)

This commit is contained in:
redtea committed 2026-10-14 19:31:25 +03:00
1 parent 8eb2bbd35d
commit 65359f5497
6 files changed
+337 -2

No files matched your search

+2
View File
@@ -19,6 +19,8 @@ var fsOps = map[string]string{
"unzip": proto.TaskFSUnzip,
"zip": proto.TaskFSZip,
"move": proto.TaskFSMove,
"stat": proto.TaskFSStat,
"chmod": proto.TaskFSChmod,
}
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
+131 -1
View File
@@ -71,6 +71,28 @@
#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; }
/* 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; }
@@ -195,6 +217,7 @@
<button class="btn ghost small" id="up">↑ Вверх</button>
<input class="fm-path" id="path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off">
<button type="button" class="busy" id="busy" title=""><span class="spinner" id="spin"></span><span id="uppct"></span></button>
<button class="btn ghost small icon sq" id="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>
<button class="btn ghost small" id="refresh">Обновить</button>
</div>
<div class="fm-bar">
@@ -226,6 +249,7 @@
<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>
@@ -266,6 +290,40 @@
<div class="dlg-b" id="updlg-list"></div>
</dialog>
<dialog id="propdlg">
<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 src="/static/codemirror/codemirror.js"></script>
@@ -299,7 +357,7 @@
// goes inside it then, so "сохранено" or an error shows over the editor.
let toastTimer = null;
const toast = (msg, err) => {
const t = $('toast'), host = document.querySelector('dialog[open]') || document.body;
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' : '');
@@ -921,6 +979,78 @@
$('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) $('propdlg').showModal();
}
$('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);
load(cwd);
};
$('propdlg-close').onclick = () => $('propdlg').close();
$('vprops').onclick = () => { if (V.path) openProps(V.path); };
$('dirprops').onclick = () => openProps(cwd);
async function del(e) {
if (e.is_dir) {
if (!confirm('Рекурсивно удалить каталог "'+e.name+'" со всем содержимым?')) return;