From beac98427d56d4fc416965d722f254fdbb8313dd Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 31 May 2026 21:02:56 -0400 Subject: [PATCH] =?UTF-8?q?ui:=20editor=20QoL=20=E2=80=94=20line=20ops,=20?= =?UTF-8?q?auto-close,=20goto,=20smart=20home,=20Ctrl+D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line operations: - Alt+↑/↓ — move current line up / down (swaps with adjacent line) - Alt+Shift+↓/↑ — duplicate line below / above - Ctrl+Shift+K — delete current line - Ctrl+L — select entire current line (including newline) Auto-close brackets and quotes: - ( [ { " ' ` — inserts matching close, cursor placed inside - Wrap selection — if text is selected, typing an opener wraps it - Skip-over — typing a close char when it's already next → jumps past it - Smart backspace — deletes BOTH chars when cursor sits between a matched pair - Guards: skip ' auto-close after word chars (contractions); skip auto-close when next char is a word char (prevents disrupting existing text) Navigation: - Ctrl+G — Go to Line bar (pre-filled with current line, shows "of N") - Smart Home — first press → first non-whitespace; second → col 0 - Ctrl+D — first press: select word at cursor; subsequent: cycle through all occurrences (wraps around), integrates with word highlight overlay Keyboard shortcuts panel: - Added "Editor Keyboard Shortcuts" section to the How do I use this panel Co-Authored-By: Claude Sonnet 4.6 --- Plugin/unraid/css/varaverk.css | 10 + Plugin/unraid/pages/scheduler.php | 306 ++++++++++++++++++++++++------ 2 files changed, 255 insertions(+), 61 deletions(-) diff --git a/Plugin/unraid/css/varaverk.css b/Plugin/unraid/css/varaverk.css index 270d2c5..1aded87 100644 --- a/Plugin/unraid/css/varaverk.css +++ b/Plugin/unraid/css/varaverk.css @@ -394,6 +394,16 @@ .vv-es-sel { color: #555; } .vv-es-lang { margin-left: auto; color: #4a6a7a; } +/* Go to line bar */ +#vv-goto-bar { display: none; align-items: center; gap: 8px; padding: 5px 8px; + margin-bottom: 5px; background: #161616; border: 1px solid #333; + border-radius: 4px; font-size: 12px; } +#vv-goto-bar.vv-goto-open { display: flex; } +#vv-goto-input { width: 68px; background: #0d0d0d; border: 1px solid #333; color: #ddd; + padding: 4px 8px; border-radius: 3px; font-family: monospace; font-size: 12px; } +#vv-goto-input:focus { border-color: #6495ed; outline: none; } +.vv-goto-info { font-size: 11px; color: #555; white-space: nowrap; flex-shrink: 0; } + /* Undo / Redo buttons */ .vv-undo-redo-btn { border-color: #444 !important; color: #666 !important; } .vv-undo-redo-btn:not([disabled]):hover { border-color: #777 !important; color: #ccc !important; background: #1e1e1e !important; } diff --git a/Plugin/unraid/pages/scheduler.php b/Plugin/unraid/pages/scheduler.php index 78c531b..ba8ef36 100644 --- a/Plugin/unraid/pages/scheduler.php +++ b/Plugin/unraid/pages/scheduler.php @@ -466,6 +466,14 @@ $runningScripts = array_unique($runningScripts);
  • Child (orch OFF) — enter a cron to run it standalone
  • Rsync badge — toggle writes TIER_RSYNC_ENABLED directly to master.conf
  • Rsync (orch OFF) — fill location + standalone cron + Save; both required for independent firing
  • +
  • Editor Keyboard Shortcuts
  • +
  • Ctrl+S — save · Ctrl+Z / Ctrl+Y — undo / redo (200 steps)
  • +
  • Ctrl+/ — toggle line comment · Tab / Shift+Tab — indent / dedent
  • +
  • Ctrl+F — find · Ctrl+G — go to line · Ctrl+D — select next occurrence
  • +
  • Ctrl+L — select line · Ctrl+Shift+K — delete line
  • +
  • Alt+↑/↓ — move line · Alt+Shift+↓ — duplicate line below
  • +
  • Home — smart (first non-space → col 0)
  • +
  • ( { [ " ` — auto-close pair; wraps selection if text is selected; Backspace deletes both when between them
  • @@ -882,6 +890,13 @@ Still the same two servers, two households, the same media stack running itself. × +
    + Go to line + + + × +
    @@ -979,6 +994,9 @@ let vvUndoStack = []; // [{v,ss,se}] snapshots — v=value, ss=selStart, s let vvRedoStack = []; let vvUndoTimer = null; const VV_UNDO_MAX = 200; // max history depth +// Auto-close pairs +const VV_PAIRS = {'(':')', '[':']', '{':'}', '"':'"', "'":"'", '`':'`'}; +const VV_CLOSE = new Set([')', ']', '}', '"', "'", '`']); // Setup mode — injected by PHP when navigating from the first-run wizard let vvSetupConf = ; @@ -2926,14 +2944,121 @@ function vvFindKeydown(e) { if (e.key === 'Escape') { e.preventDefault(); vvFindClose(); } } +// ── Go to line ──────────────────────────────────────────────────────────────── + +function vvGotoOpen() { + const bar = document.getElementById('vv-goto-bar'); + if (!bar) return; + bar.classList.add('vv-goto-open'); + const ta = document.getElementById('vv-editor-body'); + const inp = document.getElementById('vv-goto-input'); + inp.value = ta.value.slice(0, ta.selectionStart).split('\n').length; + document.getElementById('vv-goto-info').textContent = 'of ' + ta.value.split('\n').length; + inp.focus(); inp.select(); +} + +function vvGotoClose() { + document.getElementById('vv-goto-bar').classList.remove('vv-goto-open'); + document.getElementById('vv-editor-body').focus(); +} + +function vvGotoApply() { + const ta = document.getElementById('vv-editor-body'); + const lines = ta.value.split('\n'); + const n = Math.max(1, Math.min(parseInt(document.getElementById('vv-goto-input').value) || 1, lines.length)); + let pos = 0; + for (let i = 0; i < n - 1; i++) pos += lines[i].length + 1; + ta.selectionStart = ta.selectionEnd = pos; + ta.scrollTop = Math.max(0, (n - 5) * 18); + vvSyncHlOverlay(); + vvEditorCursorMoved(); +} + +function vvGotoKeydown(e) { + if (e.key === 'Enter') { e.preventDefault(); vvGotoApply(); vvGotoClose(); } + if (e.key === 'Escape') { e.preventDefault(); vvGotoClose(); } +} + +// ── Line operations ─────────────────────────────────────────────────────────── + +function vvMoveLine(dir) { + const ta = document.getElementById('vv-editor-body'); + const v = ta.value, s = ta.selectionStart; + vvUndoCapture(true); + const ls = v.lastIndexOf('\n', s - 1) + 1; + const le = v.indexOf('\n', s); + if (dir === -1) { + if (ls === 0) return; + const pls = v.lastIndexOf('\n', ls - 2) + 1; + const prev = v.slice(pls, ls - 1), cur = v.slice(ls, le === -1 ? v.length : le); + ta.value = v.slice(0, pls) + cur + '\n' + prev + v.slice(le === -1 ? v.length : le); + ta.selectionStart = ta.selectionEnd = pls + (s - ls); + } else { + if (le === -1) return; + const nle = v.indexOf('\n', le + 1); + const next = v.slice(le + 1, nle === -1 ? v.length : nle), cur = v.slice(ls, le); + ta.value = v.slice(0, ls) + next + '\n' + cur + v.slice(nle === -1 ? v.length : nle); + ta.selectionStart = ta.selectionEnd = ls + next.length + 1 + (s - ls); + } + vvSyncHlOverlay(); vvUndoCapture(true); vvEditorCursorMoved(); +} + +function vvDuplicateLine(above) { + const ta = document.getElementById('vv-editor-body'); + const v = ta.value, s = ta.selectionStart; + vvUndoCapture(true); + const ls = v.lastIndexOf('\n', s - 1) + 1; + const le = v.indexOf('\n', s); + const cur = v.slice(ls, le === -1 ? v.length : le); + if (above) { + ta.value = v.slice(0, ls) + cur + '\n' + v.slice(ls); + ta.selectionStart = ta.selectionEnd = ls + cur.length + 1 + (s - ls); + } else { + const ins = le === -1 ? v.length : le; + ta.value = v.slice(0, ins) + '\n' + cur + v.slice(ins); + ta.selectionStart = ta.selectionEnd = ins + 1 + (s - ls); + } + vvSyncHlOverlay(); vvUndoCapture(true); vvEditorCursorMoved(); +} + +// ── Select next occurrence (Ctrl+D) ────────────────────────────────────────── + +function vvSelectNextOccurrence() { + const ta = document.getElementById('vv-editor-body'); + const v = ta.value, s = ta.selectionStart, e = ta.selectionEnd; + let word; + if (e > s) { + word = v.slice(s, e); + } else { + const wb = v.slice(0, s).match(/[\w$]+$/) ?? ['']; + const wa = v.slice(s).match(/^[\w]+/) ?? ['']; + word = wb[0] + wa[0]; + if (word.length < 1) return; + // First call: just select the word at cursor + const ws = s - wb[0].length; + ta.selectionStart = ws; ta.selectionEnd = ws + word.length; + vvWordMatch = word.replace(/^\$/, ''); + vvSyncHlOverlay(); vvEditorCursorMoved(); return; + } + if (!word) return; + const re = new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'g'); + re.lastIndex = e; + let m = re.exec(v) ?? (() => { re.lastIndex = 0; return re.exec(v); })(); + if (!m || m.index === s) return; + ta.selectionStart = m.index; ta.selectionEnd = m.index + word.length; + ta.scrollTop = Math.max(0, (v.slice(0, m.index).split('\n').length - 5) * 18); + vvWordMatch = word.replace(/^\$/, ''); + vvSyncHlOverlay(); vvEditorCursorMoved(); +} + function vvEditorReset() { vvWordMatch = null; vvFindTerm = ''; vvFindMatches = []; vvFindIdx = 0; - const bar = document.getElementById('vv-editor-find'); - if (bar) { bar.classList.remove('vv-find-open'); } + document.getElementById('vv-editor-find')?.classList.remove('vv-find-open'); const inp = document.getElementById('vv-find-input'); if (inp) { inp.value = ''; inp.classList.remove('vv-find-no-match'); } const cnt = document.getElementById('vv-find-count'); if (cnt) cnt.textContent = ''; + document.getElementById('vv-goto-bar')?.classList.remove('vv-goto-open'); vvUndoReset(); } @@ -3033,64 +3158,91 @@ function vvEditorKeydown(e) { return; } - // Ctrl/Cmd+Z — undo - if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) { - e.preventDefault(); vvUndo(); return; - } - // Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z — redo - if ((e.key === 'y' && (e.ctrlKey || e.metaKey)) || - (e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey)) { - e.preventDefault(); vvRedo(); return; - } - - // Ctrl/Cmd+F — open find bar - if (e.key === 'f' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - vvFindOpen(); - return; - } - - // Escape — close find bar if open - if (e.key === 'Escape') { - const bar = document.getElementById('vv-editor-find'); - if (bar && bar.classList.contains('vv-find-open')) { - e.preventDefault(); vvFindClose(); return; + // ── Ctrl/Cmd shortcuts ──────────────────────────────────────────────────── + if (e.ctrlKey || e.metaKey) { + if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); vvUndo(); return; } + if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); vvRedo(); return; } + if (e.key === 's') { e.preventDefault(); if (vvRawConfFile) vvSaveRawConf(); else vvSaveScript(); return; } + if (e.key === 'f') { e.preventDefault(); vvFindOpen(); return; } + if (e.key === 'g') { e.preventDefault(); vvGotoOpen(); return; } + if (e.key === 'l' && !e.shiftKey) { + // Select current line + e.preventDefault(); + const ls = val.lastIndexOf('\n', start - 1) + 1; + const le = val.indexOf('\n', start); + ta.selectionStart = ls; + ta.selectionEnd = le === -1 ? val.length : le + 1; + vvEditorCursorMoved(); return; } + if (e.key === 'd' && !e.shiftKey) { e.preventDefault(); vvSelectNextOccurrence(); return; } + if (e.key === 'k' && e.shiftKey) { + // Delete current line + e.preventDefault(); vvUndoCapture(true); + const ls = val.lastIndexOf('\n', start - 1) + 1; + const le = val.indexOf('\n', start); + if (le === -1) { + ta.value = ls > 0 ? val.slice(0, ls - 1) : ''; + ta.selectionStart = ta.selectionEnd = Math.max(0, ls - 1); + } else { + ta.value = val.slice(0, ls) + val.slice(le + 1); + ta.selectionStart = ta.selectionEnd = ls; + } + vvSyncHlOverlay(); vvUndoCapture(true); return; + } + if (e.key === '/') { + // Toggle line comment + e.preventDefault(); vvUndoCapture(true); + const ls = val.lastIndexOf('\n', start - 1) + 1; + const le = val.indexOf('\n', start); + const ln = val.slice(ls, le === -1 ? undefined : le); + const nl = /^\s*#/.test(ln) ? ln.replace(/^(\s*)#\s?/, '$1') : ln.replace(/^(\s*)/, '$1# '); + ta.value = val.slice(0, ls) + nl + val.slice(le === -1 ? val.length : le); + ta.selectionStart = ta.selectionEnd = start + (nl.length - ln.length); + vvSyncHlOverlay(); vvUndoCapture(true); return; + } + return; // let browser handle other Ctrl combos } - // Ctrl/Cmd+/ — toggle line comment - if (e.key === '/' && (e.ctrlKey || e.metaKey)) { + // ── Escape ───────────────────────────────────────────────────────────────── + if (e.key === 'Escape') { + const fb = document.getElementById('vv-editor-find'); + if (fb?.classList.contains('vv-find-open')) { e.preventDefault(); vvFindClose(); return; } + const gb = document.getElementById('vv-goto-bar'); + if (gb?.classList.contains('vv-goto-open')) { e.preventDefault(); vvGotoClose(); return; } + } + + // ── Alt shortcuts (line moves) ───────────────────────────────────────────── + if (e.altKey) { + if (e.key === 'ArrowUp' && !e.shiftKey) { e.preventDefault(); vvMoveLine(-1); return; } + if (e.key === 'ArrowDown' && !e.shiftKey) { e.preventDefault(); vvMoveLine(1); return; } + if (e.key === 'ArrowDown' && e.shiftKey) { e.preventDefault(); vvDuplicateLine(false); return; } + if (e.key === 'ArrowUp' && e.shiftKey) { e.preventDefault(); vvDuplicateLine(true); return; } + } + + // ── Smart Home ───────────────────────────────────────────────────────────── + if (e.key === 'Home' && !e.altKey && !e.shiftKey) { e.preventDefault(); - vvUndoCapture(true); // snapshot before edit - const lineStart = val.lastIndexOf('\n', start - 1) + 1; - const lineEnd = val.indexOf('\n', start); - const line = val.slice(lineStart, lineEnd === -1 ? undefined : lineEnd); - const newLine = /^\s*#/.test(line) - ? line.replace(/^(\s*)#\s?/, '$1') - : line.replace(/^(\s*)/, '$1# '); - const delta = newLine.length - line.length; - ta.value = val.slice(0, lineStart) + newLine + val.slice(lineEnd === -1 ? val.length : lineEnd); - ta.selectionStart = ta.selectionEnd = start + delta; - vvSyncHlOverlay(); vvUndoCapture(true); return; + const ls = val.lastIndexOf('\n', start - 1) + 1; + const fns = ls + val.slice(ls).match(/^(\s*)/)[1].length; + ta.selectionStart = ta.selectionEnd = start === fns ? ls : fns; + vvEditorCursorMoved(); return; } - // Tab / Shift+Tab — indent / dedent (2 spaces) + // ── Tab / Shift+Tab ──────────────────────────────────────────────────────── if (e.key === 'Tab') { - e.preventDefault(); - vvUndoCapture(true); // snapshot before edit + e.preventDefault(); vvUndoCapture(true); if (end > start && val.slice(start, end).includes('\n')) { - const blockStart = val.lastIndexOf('\n', start - 1) + 1; - const blockEnd = val.indexOf('\n', end - 1); - const block = val.slice(blockStart, blockEnd === -1 ? val.length : blockEnd); - const newBlock = e.shiftKey ? block.replace(/^ /gm, '') : block.replace(/^/gm, ' '); - ta.value = val.slice(0, blockStart) + newBlock + val.slice(blockEnd === -1 ? val.length : blockEnd); - ta.selectionStart = blockStart; - ta.selectionEnd = blockStart + newBlock.length; + const bs = val.lastIndexOf('\n', start - 1) + 1; + const be = val.indexOf('\n', end - 1); + const blk = val.slice(bs, be === -1 ? val.length : be); + const nb = e.shiftKey ? blk.replace(/^ /gm, '') : blk.replace(/^/gm, ' '); + ta.value = val.slice(0, bs) + nb + val.slice(be === -1 ? val.length : be); + ta.selectionStart = bs; ta.selectionEnd = bs + nb.length; } else if (e.shiftKey) { - const lineStart = val.lastIndexOf('\n', start - 1) + 1; - if (val.slice(lineStart, lineStart + 2) === ' ') { - ta.value = val.slice(0, lineStart) + val.slice(lineStart + 2); - ta.selectionStart = ta.selectionEnd = Math.max(lineStart, start - 2); + const ls = val.lastIndexOf('\n', start - 1) + 1; + if (val.slice(ls, ls + 2) === ' ') { + ta.value = val.slice(0, ls) + val.slice(ls + 2); + ta.selectionStart = ta.selectionEnd = Math.max(ls, start - 2); } } else { ta.value = val.slice(0, start) + ' ' + val.slice(end); @@ -3099,19 +3251,51 @@ function vvEditorKeydown(e) { vvSyncHlOverlay(); vvUndoCapture(true); return; } - // Enter — auto-indent + // ── Enter auto-indent ────────────────────────────────────────────────────── if (e.key === 'Enter') { - e.preventDefault(); - vvUndoCapture(true); // snapshot before edit - const lineStart = val.lastIndexOf('\n', start - 1) + 1; - const currentLine = val.slice(lineStart, start); - const indent = currentLine.match(/^(\s*)/)[1]; - const extra = /\{\s*$/.test(currentLine) ? ' ' : ''; - const insert = '\n' + indent + extra; - ta.value = val.slice(0, start) + insert + val.slice(end); - ta.selectionStart = ta.selectionEnd = start + insert.length; + e.preventDefault(); vvUndoCapture(true); + const ls = val.lastIndexOf('\n', start - 1) + 1; + const curL = val.slice(ls, start); + const ind = curL.match(/^(\s*)/)[1]; + const xtra = /\{\s*$/.test(curL) ? ' ' : ''; + const ins = '\n' + ind + xtra; + ta.value = val.slice(0, start) + ins + val.slice(end); + ta.selectionStart = ta.selectionEnd = start + ins.length; vvSyncHlOverlay(); vvUndoCapture(true); return; } + + // ── Auto-close brackets and quotes (no modifier keys) ───────────────────── + if (!e.altKey) { + // Skip over closing char if already there + if (VV_CLOSE.has(e.key) && start === end && val[start] === e.key) { + e.preventDefault(); + ta.selectionStart = ta.selectionEnd = start + 1; + vvEditorCursorMoved(); return; + } + // Smart backspace: delete matched pair + if (e.key === 'Backspace' && start === end && start > 0 && VV_PAIRS[val[start-1]] === val[start]) { + e.preventDefault(); vvUndoCapture(true); + ta.value = val.slice(0, start - 1) + val.slice(start + 1); + ta.selectionStart = ta.selectionEnd = start - 1; + vvSyncHlOverlay(); vvUndoCapture(true); return; + } + // Auto-close opening bracket/quote + if (e.key in VV_PAIRS) { + if (e.key === "'" && /\w/.test(val[start - 1] ?? '')) return; // skip contractions + if (end === start && /\w/.test(val[start] ?? '')) return; // skip if next is word char + e.preventDefault(); vvUndoCapture(true); + const cl = VV_PAIRS[e.key]; + if (end > start) { + // Wrap selection in pair + ta.value = val.slice(0, start) + e.key + val.slice(start, end) + cl + val.slice(end); + ta.selectionStart = start + 1; ta.selectionEnd = end + 1; + } else { + ta.value = val.slice(0, start) + e.key + cl + val.slice(end); + ta.selectionStart = ta.selectionEnd = start + 1; + } + vvSyncHlOverlay(); vvUndoCapture(true); return; + } + } } // ── Script browser tree ───────────────────────────────────────────────────────