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);
@@ -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 = = json_encode($vv_setup_conf) ?>;
@@ -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 ───────────────────────────────────────────────────────