skopix 2.0.73 → 2.0.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1870,6 +1870,69 @@ export async function dashboardCommand(options) {
1870
1870
  return;
1871
1871
  }
1872
1872
 
1873
+ // ─── FOLDERS ───────────────────────────────────────────────────────
1874
+ if (pathname === '/api/folders' && method === 'GET') {
1875
+ sendJSON(res, 200, await getFolders());
1876
+ return;
1877
+ }
1878
+ if (pathname === '/api/folders' && method === 'POST') {
1879
+ const { name, type } = JSON.parse(await readBody(req));
1880
+ const folders = await getFolders();
1881
+ if (!folders.find(f => f.name === name && f.type === type)) {
1882
+ folders.push({ name, type, createdAt: new Date().toISOString() });
1883
+ await saveFolders(folders);
1884
+ }
1885
+ sendJSON(res, 200, folders);
1886
+ return;
1887
+ }
1888
+ if (pathname === '/api/folders' && method === 'DELETE') {
1889
+ const { name, type } = JSON.parse(await readBody(req));
1890
+ const folders = await getFolders();
1891
+ await saveFolders(folders.filter(f => !(f.name === name && f.type === type)));
1892
+ sendJSON(res, 200, { deleted: true });
1893
+ return;
1894
+ }
1895
+ if (pathname === '/api/folders/rename' && method === 'POST') {
1896
+ const { oldName, newName, type } = JSON.parse(await readBody(req));
1897
+ const folders = await getFolders();
1898
+ const idx = folders.findIndex(f => f.name === oldName && f.type === type);
1899
+ if (idx !== -1) folders[idx].name = newName;
1900
+ await saveFolders(folders);
1901
+ // Update all items that used the old folder name
1902
+ if (type === 'library') {
1903
+ const steps = await listLibrarySteps(suitesDir);
1904
+ steps.forEach(s => { if (s.folder === oldName) s.folder = newName; });
1905
+ await saveLibrarySteps(steps);
1906
+ } else if (type === 'tests') {
1907
+ const allTests = await listAllTests(suitesDir);
1908
+ for (const test of allTests) {
1909
+ if (test.folder === oldName) await updateTest(suitesDir, test.scope, test.id, { ...test, folder: newName });
1910
+ }
1911
+ }
1912
+ sendJSON(res, 200, { renamed: true });
1913
+ return;
1914
+ }
1915
+ if (pathname === '/api/folders/move-items' && method === 'POST') {
1916
+ const { ids, folder, type, fromFolder } = JSON.parse(await readBody(req));
1917
+ if (type === 'library') {
1918
+ const steps = await listLibrarySteps(suitesDir);
1919
+ steps.forEach(s => {
1920
+ if (ids === '__all_in_folder__' ? s.folder === fromFolder : ids.includes(s.id)) {
1921
+ s.folder = folder || null;
1922
+ }
1923
+ });
1924
+ await saveLibrarySteps(steps);
1925
+ } else if (type === 'tests') {
1926
+ const allTests = await listAllTests(suitesDir);
1927
+ for (const test of allTests) {
1928
+ const match = ids === '__all_in_folder__' ? test.folder === fromFolder : ids.includes(test.id);
1929
+ if (match) await updateTest(suitesDir, test.scope, test.id, { ...test, folder: folder || null });
1930
+ }
1931
+ }
1932
+ sendJSON(res, 200, { moved: ids === '__all_in_folder__' ? -1 : ids.length });
1933
+ return;
1934
+ }
1935
+
1873
1936
  // ─── STEP LIBRARY ──────────────────────────────────────────────────
1874
1937
  if (pathname.match(/^\/api\/step-library\/[^/]+\/usages$/) && method === 'GET') {
1875
1938
  try {
@@ -3786,6 +3849,24 @@ async function syncIssuesStatus() {
3786
3849
  // STEP LIBRARY — persistent store of reusable UI interactions
3787
3850
  // ═══════════════════════════════════════════════════════════════
3788
3851
 
3852
+ const FOLDERS_FILE = () => path.join(os.homedir(), '.skopix', 'folders.yaml');
3853
+
3854
+ async function getFolders() {
3855
+ const file = FOLDERS_FILE();
3856
+ if (!await fs.pathExists(file)) return [];
3857
+ try {
3858
+ const content = await fs.readFile(file, 'utf8');
3859
+ const data = yaml.parse(content);
3860
+ return Array.isArray(data) ? data : [];
3861
+ } catch { return []; }
3862
+ }
3863
+
3864
+ async function saveFolders(folders) {
3865
+ const file = FOLDERS_FILE();
3866
+ await fs.ensureDir(path.dirname(file));
3867
+ await fs.writeFile(file, yaml.stringify(folders));
3868
+ }
3869
+
3789
3870
  const SAVED_URLS_FILE = () => path.join(os.homedir(), '.skopix', 'saved-urls.yaml');
3790
3871
 
3791
3872
  async function getSavedUrls() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.73",
3
+ "version": "2.0.74",
4
4
  "description": "Browser-based QA tool — record tests by using your app, replay them deterministically, generate Playwright code automatically",
5
5
  "main": "cli/index.js",
6
6
  "bin": {
@@ -1531,22 +1531,36 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
1531
1531
  </div>
1532
1532
  </div>
1533
1533
 
1534
- <div class="card">
1535
- <div class="card-header" style="flex-wrap:wrap;gap:12px">
1536
- <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
1537
- <input class="form-input" type="text" id="tests-search" placeholder="Search by name or goal..." style="width:280px;padding:8px 12px;font-size:12px" oninput="renderAllTests()">
1538
- <select class="form-select" id="tests-scope-filter" style="padding:8px 12px;font-size:12px;width:auto" onchange="renderAllTests()">
1534
+ <!-- Two-panel layout: folder tree + tests -->
1535
+ <div style="display:grid;grid-template-columns:220px 1fr;gap:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--surface)">
1536
+
1537
+ <!-- LEFT: Folder tree -->
1538
+ <div style="border-right:1px solid var(--border);display:flex;flex-direction:column;background:var(--surface2)">
1539
+ <div style="padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between">
1540
+ <span style="font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:0.1em">FOLDERS</span>
1541
+ <button class="btn btn-ghost" style="padding:2px 6px;font-size:14px;line-height:1" onclick="openCreateFolder('tests')" title="New folder">+</button>
1542
+ </div>
1543
+ <div id="tests-folder-tree" style="overflow-y:auto;flex:1;padding:6px 0"></div>
1544
+ </div>
1545
+
1546
+ <!-- RIGHT: Tests content -->
1547
+ <div style="display:flex;flex-direction:column;overflow:hidden">
1548
+ <div style="padding:10px 14px;border-bottom:1px solid var(--border);display:flex;gap:8px;align-items:center;flex-wrap:wrap">
1549
+ <input class="form-input" type="text" id="tests-search" placeholder="Search by name or goal..." style="flex:1;min-width:180px;padding:6px 10px;font-size:12px" oninput="renderAllTests()">
1550
+ <select class="form-select" id="tests-scope-filter" style="padding:6px 10px;font-size:12px;width:auto" onchange="renderAllTests()">
1539
1551
  <option value="">All scopes</option>
1540
1552
  </select>
1541
- <select class="form-select" id="tests-tag-filter" style="padding:8px 12px;font-size:12px;width:auto" onchange="renderAllTests()">
1553
+ <select class="form-select" id="tests-tag-filter" style="padding:6px 10px;font-size:12px;width:auto" onchange="renderAllTests()">
1542
1554
  <option value="">All tags</option>
1543
1555
  </select>
1556
+ <button class="btn btn-ghost" id="btn-move-tests-folder" style="display:none;font-size:11px;padding:4px 10px" onclick="openMoveToFolder('tests')">
1557
+ Move to folder
1558
+ </button>
1559
+ <div id="tests-count" style="font-family:var(--mono);font-size:11px;color:var(--muted)"></div>
1544
1560
  </div>
1545
- <div id="tests-count" style="font-family:var(--mono);font-size:11px;color:var(--muted)"></div>
1546
- </div>
1547
- <div class="card-body" style="padding:0">
1548
- <div id="all-tests-container"><!-- populated --></div>
1561
+ <div id="all-tests-container" style="overflow-y:auto;flex:1"><!-- populated --></div>
1549
1562
  </div>
1563
+
1550
1564
  </div>
1551
1565
  </div>
1552
1566
 
@@ -1610,18 +1624,33 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
1610
1624
  </div>
1611
1625
  </div>
1612
1626
 
1613
- <!-- Search + filter -->
1614
- <div style="display:flex;gap:10px;margin-bottom:16px">
1615
- <input class="form-input" id="library-search" type="text" placeholder="Search elements..." oninput="filterLibrarySteps()" style="flex:1">
1616
- <select class="form-select" id="library-filter-tag" onchange="filterLibrarySteps()" style="width:140px">
1617
- <option value="">All tags</option>
1618
- </select>
1619
- </div>
1627
+ <!-- Two-panel layout: folder tree + content -->
1628
+ <div style="display:grid;grid-template-columns:220px 1fr;gap:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--surface)">
1620
1629
 
1621
- <div class="card">
1622
- <div class="card-body" style="padding:0">
1623
- <div id="library-steps-container"><!-- populated --></div>
1630
+ <!-- LEFT: Folder tree -->
1631
+ <div style="border-right:1px solid var(--border);display:flex;flex-direction:column;background:var(--surface2)">
1632
+ <div style="padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between">
1633
+ <span style="font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:0.1em">FOLDERS</span>
1634
+ <button class="btn btn-ghost" style="padding:2px 6px;font-size:14px;line-height:1" onclick="openCreateFolder('library')" title="New folder">+</button>
1635
+ </div>
1636
+ <div id="library-folder-tree" style="overflow-y:auto;flex:1;padding:6px 0"></div>
1624
1637
  </div>
1638
+
1639
+ <!-- RIGHT: Steps content -->
1640
+ <div style="display:flex;flex-direction:column;overflow:hidden">
1641
+ <!-- Search + filter + bulk actions -->
1642
+ <div style="padding:10px 14px;border-bottom:1px solid var(--border);display:flex;gap:8px;align-items:center">
1643
+ <input class="form-input" id="library-search" type="text" placeholder="Search elements..." oninput="filterLibrarySteps()" style="flex:1;font-size:12px">
1644
+ <select class="form-select" id="library-filter-tag" onchange="filterLibrarySteps()" style="width:130px;font-size:12px">
1645
+ <option value="">All tags</option>
1646
+ </select>
1647
+ <button class="btn btn-ghost" id="btn-move-to-folder" style="display:none;font-size:11px;padding:4px 10px" onclick="openMoveToFolder('library')">
1648
+ Move to folder
1649
+ </button>
1650
+ </div>
1651
+ <div id="library-steps-container" style="overflow-y:auto;flex:1"><!-- populated --></div>
1652
+ </div>
1653
+
1625
1654
  </div>
1626
1655
  </div>
1627
1656
 
@@ -1828,6 +1857,47 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
1828
1857
  </div>
1829
1858
  </div>
1830
1859
 
1860
+ <!-- CREATE FOLDER MODAL -->
1861
+ <div class="modal-overlay" id="create-folder-modal" style="display:none" onclick="if(event.target===this)closeCreateFolder()">
1862
+ <div class="modal" style="max-width:400px;width:100%">
1863
+ <div class="modal-header">
1864
+ <div class="modal-title">New folder</div>
1865
+ <button class="modal-close" onclick="closeCreateFolder()">✕</button>
1866
+ </div>
1867
+ <div class="modal-body" style="display:flex;flex-direction:column;gap:14px">
1868
+ <input type="hidden" id="create-folder-type">
1869
+ <div class="form-field">
1870
+ <label class="form-label">Folder name</label>
1871
+ <input class="form-input" id="create-folder-name" type="text" placeholder="e.g. Login, Charts/Bar Charts" onkeydown="if(event.key==='Enter')confirmCreateFolder()">
1872
+ <div class="form-help">Use / for nested folders e.g. "Charts/Bar Charts"</div>
1873
+ </div>
1874
+ </div>
1875
+ <div class="modal-footer">
1876
+ <button class="btn btn-ghost" onclick="closeCreateFolder()">Cancel</button>
1877
+ <button class="btn btn-primary" onclick="confirmCreateFolder()">Create</button>
1878
+ </div>
1879
+ </div>
1880
+ </div>
1881
+
1882
+ <!-- MOVE TO FOLDER MODAL -->
1883
+ <div class="modal-overlay" id="move-folder-modal" style="display:none" onclick="if(event.target===this)closeMoveToFolder()">
1884
+ <div class="modal" style="max-width:400px;width:100%">
1885
+ <div class="modal-header">
1886
+ <div class="modal-title" id="move-folder-title">Move to folder</div>
1887
+ <button class="modal-close" onclick="closeMoveToFolder()">✕</button>
1888
+ </div>
1889
+ <div class="modal-body" style="display:flex;flex-direction:column;gap:10px">
1890
+ <input type="hidden" id="move-folder-type">
1891
+ <div id="move-folder-list" style="display:flex;flex-direction:column;gap:6px;max-height:300px;overflow-y:auto"></div>
1892
+ <div style="border-top:1px solid var(--border);padding-top:10px;margin-top:4px">
1893
+ <button class="btn btn-ghost" style="width:100%;justify-content:center;color:var(--muted)" onclick="confirmMoveToFolder(null)">
1894
+ Remove from folder (move to root)
1895
+ </button>
1896
+ </div>
1897
+ </div>
1898
+ </div>
1899
+ </div>
1900
+
1831
1901
  <!-- USAGES POPOVER -->
1832
1902
  <div id="lib-usages-popover" style="display:none;position:fixed;z-index:9999;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:0;min-width:220px;max-width:320px;box-shadow:0 8px 32px rgba(0,0,0,0.4)">
1833
1903
  <div style="padding:10px 14px;border-bottom:1px solid var(--border);font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:0.1em;display:flex;justify-content:space-between;align-items:center">
@@ -4084,6 +4154,9 @@ function renderAllTests() {
4084
4154
  if ([...tagFilter.options].some(o => o.value === currentTag)) tagFilter.value = currentTag;
4085
4155
  }
4086
4156
 
4157
+ // Render folder tree
4158
+ renderFolderTree('tests-folder-tree', 'tests', selectedTestsFolder);
4159
+
4087
4160
  // Apply filters
4088
4161
  const search = (document.getElementById('tests-search')?.value || '').toLowerCase();
4089
4162
  const scope = document.getElementById('tests-scope-filter')?.value || '';
@@ -4096,6 +4169,7 @@ function renderAllTests() {
4096
4169
  const haystack = ((t.name || '') + ' ' + (t.goal || '')).toLowerCase();
4097
4170
  if (!haystack.includes(search)) return false;
4098
4171
  }
4172
+ if (selectedTestsFolder !== null && t.folder !== selectedTestsFolder) return false;
4099
4173
  return true;
4100
4174
  });
4101
4175
  filtered = sortTests(filtered);
@@ -4115,11 +4189,14 @@ function renderAllTests() {
4115
4189
  const stepCount = isRec ? (t.steps || []).length : 0;
4116
4190
  const reusableBadge = isRec && t.reusable ? '<span style="font-family:var(--mono);font-size:10px;background:rgba(245,158,11,0.1);color:#f59e0b;border:1px solid rgba(245,158,11,0.2);border-radius:4px;padding:2px 6px;margin-right:5px">REUSABLE</span>' : '';
4117
4191
  const setupBadge = isRec && t.setup ? '<span style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-left:4px">via ' + escapeHtml(t.setup) + '</span>' : '';
4192
+ const folderBadge = t.folder ? '<span style="font-family:var(--mono);font-size:10px;padding:2px 6px;background:rgba(34,211,238,0.1);border-radius:4px;color:#22d3ee;margin-right:4px">📁 ' + escapeHtml(t.folder) + '</span>' : '';
4118
4193
  const subtitle = isRec
4119
4194
  ? '<span style="font-family:var(--mono);font-size:10px;background:rgba(6,182,212,0.1);color:var(--cyan);border:1px solid rgba(6,182,212,0.2);border-radius:4px;padding:2px 7px;margin-right:6px">RECORDED</span>' + reusableBadge + '<span style="font-family:var(--mono);font-size:11px;color:var(--muted)">' + stepCount + ' steps' + setupBadge + '</span>'
4120
4195
  : '<span style="font-family:var(--mono);font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:380px;display:inline-block">' + escapeHtml(t.goal || '') + '</span>';
4121
- return '<tr>'
4122
- + '<td><div style="font-weight:600;color:var(--white);margin-bottom:4px">' + escapeHtml(t.name) + '</div><div>' + subtitle + '</div></td>'
4196
+ return '<tr draggable="true" ondragstart="onItemDragStart(event,\'' + escapeAttr(t.id) + '\',\'tests\')">'
4197
+ + '<td style="padding:10px 8px;color:var(--muted2);cursor:grab;font-size:14px;width:20px">⠿</td>'
4198
+ + '<td style="padding:10px 8px;width:20px"><input type="checkbox" class="test-checkbox" data-id="' + escapeAttr(t.id) + '" onchange="onTestCheck()" style="width:14px;height:14px"></td>'
4199
+ + '<td><div style="font-weight:600;color:var(--white);margin-bottom:4px">' + escapeHtml(t.name) + '</div><div>' + folderBadge + subtitle + '</div></td>'
4123
4200
  + '<td><span class="scope-pill ' + (t.scopeIsSaved ? 'saved' : 'suite') + '">' + escapeHtml(t.scopeName) + '</span></td>'
4124
4201
  + '<td>' + (t.credentials ? '<span class="suite-tag-pill" style="color:var(--purple);background:var(--purple-dim);border-color:rgba(124,58,237,0.3)"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle;margin-right:3px"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>' + escapeHtml(t.credentials) + '</span>' : '<span style="color:var(--muted2);font-family:var(--mono);font-size:11px">—</span>') + '</td>'
4125
4202
  + '<td><div style="display:flex;gap:4px;flex-wrap:wrap">' + ((t.tags || []).map(tg => '<span class="suite-tag-pill">' + escapeHtml(tg) + '</span>').join('') || '<span style="color:var(--muted2);font-family:var(--mono);font-size:11px">—</span>') + '</div></td>'
@@ -4148,6 +4225,8 @@ function renderAllTests() {
4148
4225
  container.innerHTML = `
4149
4226
  <table class="tests-table">
4150
4227
  <thead><tr>
4228
+ <th style="width:20px"></th>
4229
+ <th style="width:20px"></th>
4151
4230
  <th style="cursor:pointer;user-select:none" onclick="setTestsSort('name')">Test ${getSortIndicator('name')}</th>
4152
4231
  <th style="cursor:pointer;user-select:none" onclick="setTestsSort('scope')">Scope ${getSortIndicator('scope')}</th>
4153
4232
  <th style="cursor:pointer;user-select:none" onclick="setTestsSort('credentials')">Credentials ${getSortIndicator('credentials')}</th>
@@ -4526,7 +4605,7 @@ const _origSwitchView2 = switchView;
4526
4605
  switchView = function(name) {
4527
4606
  _origSwitchView2(name);
4528
4607
  if (name === 'all-tests') {
4529
- fetchAllTests().then(renderAllTests);
4608
+ fetchAllTests().then(() => { fetchFolders().then(() => renderAllTests()); });
4530
4609
  }
4531
4610
  if (name === 'suite-runs') {
4532
4611
  fetchSuiteRuns().then(renderSuiteRuns);
@@ -5606,6 +5685,244 @@ async function saveOllamaConfig() {
5606
5685
  }
5607
5686
  }
5608
5687
 
5688
+ function getSelectedTestIds() {
5689
+ return Array.from(document.querySelectorAll('.test-checkbox:checked')).map(cb => cb.dataset.id);
5690
+ }
5691
+
5692
+ function onTestCheck() {
5693
+ const checked = document.querySelectorAll('.test-checkbox:checked');
5694
+ const moveBtn = document.getElementById('btn-move-tests-folder');
5695
+ if (moveBtn) moveBtn.style.display = checked.length >= 1 ? '' : 'none';
5696
+ }
5697
+
5698
+ // ── FOLDERS ──────────────────────────────────────────────────────────────────
5699
+ let foldersCache = [];
5700
+ let selectedLibraryFolder = null; // null = All
5701
+ let selectedTestsFolder = null; // null = All
5702
+
5703
+ async function fetchFolders() {
5704
+ try {
5705
+ const res = await fetch(API_BASE + '/api/folders');
5706
+ if (!res.ok) return [];
5707
+ foldersCache = await res.json();
5708
+ return foldersCache;
5709
+ } catch { return []; }
5710
+ }
5711
+
5712
+ // Build nested folder structure from flat list
5713
+ function buildFolderTree(folders, type) {
5714
+ const nodes = {};
5715
+ const roots = [];
5716
+ folders.filter(f => f.type === type).forEach(f => {
5717
+ const parts = f.name.split('/');
5718
+ let path = '';
5719
+ parts.forEach((part, i) => {
5720
+ const parentPath = path;
5721
+ path = path ? path + '/' + part : part;
5722
+ if (!nodes[path]) {
5723
+ nodes[path] = { name: part, fullPath: path, children: [], parentPath };
5724
+ if (i === 0) roots.push(nodes[path]);
5725
+ else if (nodes[parentPath]) nodes[parentPath].children.push(nodes[path]);
5726
+ }
5727
+ });
5728
+ });
5729
+ return roots;
5730
+ }
5731
+
5732
+ function renderFolderTree(containerId, type, selectedFolder, onSelect) {
5733
+ const container = document.getElementById(containerId);
5734
+ if (!container) return;
5735
+ const roots = buildFolderTree(foldersCache, type);
5736
+
5737
+ function renderNode(node, depth = 0) {
5738
+ const isSelected = selectedFolder === node.fullPath;
5739
+ return `<div>
5740
+ <div class="folder-item ${isSelected ? 'folder-item--active' : ''}"
5741
+ style="padding:7px 14px 7px ${14 + depth * 16}px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:12px;color:${isSelected ? 'var(--cyan)' : 'var(--text)'};background:${isSelected ? 'rgba(34,211,238,0.08)' : ''};transition:background 0.1s"
5742
+ data-folder="${escapeAttr(node.fullPath)}" data-type="${type}"
5743
+ onmouseover="if(!this.classList.contains('folder-item--active'))this.style.background='var(--surface2)'"
5744
+ onmouseout="if(!this.classList.contains('folder-item--active'))this.style.background=''"
5745
+ ondragover="event.preventDefault();this.style.background='rgba(34,211,238,0.15)'"
5746
+ ondragleave="this.style.background=''"
5747
+ ondrop="onFolderDrop(event,'${escapeAttr(node.fullPath)}','${type}')">
5748
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="${isSelected ? 'rgba(34,211,238,0.3)' : 'none'}" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
5749
+ <span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(node.name)}</span>
5750
+ <span class="folder-actions" style="display:none;gap:4px">
5751
+ <span data-rename-folder="${escapeAttr(node.fullPath)}" data-type="${type}" style="opacity:0.5;cursor:pointer;font-size:11px" title="Rename">✎</span>
5752
+ <span data-delete-folder="${escapeAttr(node.fullPath)}" data-type="${type}" style="opacity:0.5;cursor:pointer;font-size:11px;color:var(--red)" title="Delete">✕</span>
5753
+ </span>
5754
+ </div>
5755
+ ${node.children.map(c => renderNode(c, depth + 1)).join('')}
5756
+ </div>`;
5757
+ }
5758
+
5759
+ const allActive = selectedFolder === null;
5760
+ container.innerHTML = `
5761
+ <div style="padding:7px 14px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:12px;color:${allActive ? 'var(--cyan)' : 'var(--muted)'};background:${allActive ? 'rgba(34,211,238,0.08)' : ''}"
5762
+ data-folder="" data-type="${type}"
5763
+ onmouseover="if(${!allActive})this.style.background='var(--surface2)'" onmouseout="if(${!allActive})this.style.background=''"
5764
+ ondragover="event.preventDefault();this.style.background='rgba(34,211,238,0.15)'"
5765
+ ondragleave="this.style.background=''"
5766
+ ondrop="onFolderDrop(event,'','${type}')">
5767
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/></svg>
5768
+ All
5769
+ </div>
5770
+ ${roots.map(n => renderNode(n)).join('')}`;
5771
+
5772
+ // Show/hide actions on hover
5773
+ container.querySelectorAll('.folder-item').forEach(el => {
5774
+ el.addEventListener('mouseenter', () => { el.querySelector('.folder-actions').style.display = 'flex'; });
5775
+ el.addEventListener('mouseleave', () => { el.querySelector('.folder-actions').style.display = 'none'; });
5776
+ });
5777
+ }
5778
+
5779
+ // Folder click delegation
5780
+ document.addEventListener('click', (e) => {
5781
+ const folderItem = e.target.closest('[data-folder][data-type]');
5782
+ const renameBtn = e.target.closest('[data-rename-folder]');
5783
+ const deleteBtn = e.target.closest('[data-delete-folder]');
5784
+
5785
+ if (renameBtn) {
5786
+ e.stopPropagation();
5787
+ const name = renameBtn.dataset.renameFolder;
5788
+ const type = renameBtn.dataset.type;
5789
+ const newName = prompt('Rename folder:', name);
5790
+ if (newName && newName !== name) renameFolder(name, newName, type);
5791
+ return;
5792
+ }
5793
+ if (deleteBtn) {
5794
+ e.stopPropagation();
5795
+ const name = deleteBtn.dataset.deleteFolder;
5796
+ const type = deleteBtn.dataset.type;
5797
+ showConfirm('Delete folder?', `Delete "${name}"? Items inside will be moved to root.`, () => deleteFolder(name, type));
5798
+ return;
5799
+ }
5800
+ if (folderItem && !renameBtn && !deleteBtn) {
5801
+ const folder = folderItem.dataset.folder || null;
5802
+ const type = folderItem.dataset.type;
5803
+ if (type === 'library') { selectedLibraryFolder = folder; loadLibraryView(); }
5804
+ else if (type === 'tests') { selectedTestsFolder = folder; fetchAllTests().then(renderAllTests); }
5805
+ }
5806
+ });
5807
+
5808
+ // Drag and drop
5809
+ function onItemDragStart(e, id, type) {
5810
+ e.dataTransfer.setData('text/plain', JSON.stringify({ id, type }));
5811
+ e.dataTransfer.effectAllowed = 'move';
5812
+ }
5813
+
5814
+ async function onFolderDrop(e, folderPath, type) {
5815
+ e.preventDefault();
5816
+ e.currentTarget.style.background = '';
5817
+ try {
5818
+ const data = JSON.parse(e.dataTransfer.getData('text/plain'));
5819
+ if (data.type !== type) return;
5820
+ await fetch(API_BASE + '/api/folders/move-items', {
5821
+ method: 'POST',
5822
+ headers: { 'Content-Type': 'application/json' },
5823
+ body: JSON.stringify({ ids: [data.id], folder: folderPath || null, type }),
5824
+ });
5825
+ if (type === 'library') loadLibraryView();
5826
+ else fetchAllTests().then(renderAllTests);
5827
+ } catch {}
5828
+ }
5829
+
5830
+ // Create folder
5831
+ function openCreateFolder(type) {
5832
+ document.getElementById('create-folder-type').value = type;
5833
+ document.getElementById('create-folder-name').value = '';
5834
+ document.getElementById('create-folder-modal').style.display = 'flex';
5835
+ setTimeout(() => document.getElementById('create-folder-name').focus(), 100);
5836
+ }
5837
+
5838
+ function closeCreateFolder() {
5839
+ document.getElementById('create-folder-modal').style.display = 'none';
5840
+ }
5841
+
5842
+ async function confirmCreateFolder() {
5843
+ const type = document.getElementById('create-folder-type').value;
5844
+ const name = document.getElementById('create-folder-name').value.trim();
5845
+ if (!name) { showToast('Enter a folder name'); return; }
5846
+ await fetch(API_BASE + '/api/folders', {
5847
+ method: 'POST',
5848
+ headers: { 'Content-Type': 'application/json' },
5849
+ body: JSON.stringify({ name, type }),
5850
+ });
5851
+ foldersCache = await (await fetch(API_BASE + '/api/folders')).json();
5852
+ closeCreateFolder();
5853
+ if (type === 'library') loadLibraryView();
5854
+ else { renderFolderTree('tests-folder-tree', 'tests', selectedTestsFolder); }
5855
+ showToast(`Folder "${name}" created`);
5856
+ }
5857
+
5858
+ // Rename / delete folder
5859
+ async function renameFolder(oldName, newName, type) {
5860
+ await fetch(API_BASE + '/api/folders/rename', {
5861
+ method: 'POST',
5862
+ headers: { 'Content-Type': 'application/json' },
5863
+ body: JSON.stringify({ oldName, newName, type }),
5864
+ });
5865
+ foldersCache = await (await fetch(API_BASE + '/api/folders')).json();
5866
+ if (type === 'library') { if (selectedLibraryFolder === oldName) selectedLibraryFolder = newName; loadLibraryView(); }
5867
+ else { if (selectedTestsFolder === oldName) selectedTestsFolder = newName; fetchAllTests().then(renderAllTests); }
5868
+ }
5869
+
5870
+ async function deleteFolder(name, type) {
5871
+ // Move items in this folder to root first
5872
+ await fetch(API_BASE + '/api/folders/move-items', {
5873
+ method: 'POST',
5874
+ headers: { 'Content-Type': 'application/json' },
5875
+ body: JSON.stringify({ ids: '__all_in_folder__', folder: null, type, fromFolder: name }),
5876
+ });
5877
+ await fetch(API_BASE + '/api/folders', {
5878
+ method: 'DELETE',
5879
+ headers: { 'Content-Type': 'application/json' },
5880
+ body: JSON.stringify({ name, type }),
5881
+ });
5882
+ foldersCache = await (await fetch(API_BASE + '/api/folders')).json();
5883
+ if (type === 'library') { if (selectedLibraryFolder === name) selectedLibraryFolder = null; loadLibraryView(); }
5884
+ else { if (selectedTestsFolder === name) selectedTestsFolder = null; fetchAllTests().then(renderAllTests); }
5885
+ }
5886
+
5887
+ // Move to folder modal
5888
+ let moveFolderIds = [];
5889
+ function openMoveToFolder(type) {
5890
+ const ids = type === 'library' ? getSelectedStepIds() : getSelectedTestIds();
5891
+ if (!ids.length) return;
5892
+ moveFolderIds = ids;
5893
+ document.getElementById('move-folder-type').value = type;
5894
+ document.getElementById('move-folder-title').textContent = `Move ${ids.length} item${ids.length > 1 ? 's' : ''} to folder`;
5895
+ const folders = foldersCache.filter(f => f.type === type);
5896
+ const list = document.getElementById('move-folder-list');
5897
+ if (!folders.length) {
5898
+ list.innerHTML = `<div style="font-family:var(--mono);font-size:12px;color:var(--muted);text-align:center;padding:20px">No folders yet — create one first</div>`;
5899
+ } else {
5900
+ list.innerHTML = folders.map(f => `
5901
+ <button class="btn btn-ghost" style="width:100%;justify-content:flex-start;gap:8px;padding:8px 12px" onclick="confirmMoveToFolder('${escapeAttr(f.name)}')">
5902
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
5903
+ ${escapeHtml(f.name)}
5904
+ </button>`).join('');
5905
+ }
5906
+ document.getElementById('move-folder-modal').style.display = 'flex';
5907
+ }
5908
+
5909
+ function closeMoveToFolder() {
5910
+ document.getElementById('move-folder-modal').style.display = 'none';
5911
+ }
5912
+
5913
+ async function confirmMoveToFolder(folderName) {
5914
+ const type = document.getElementById('move-folder-type').value;
5915
+ closeMoveToFolder();
5916
+ await fetch(API_BASE + '/api/folders/move-items', {
5917
+ method: 'POST',
5918
+ headers: { 'Content-Type': 'application/json' },
5919
+ body: JSON.stringify({ ids: moveFolderIds, folder: folderName, type }),
5920
+ });
5921
+ showToast(folderName ? `Moved to "${folderName}"` : 'Moved to root');
5922
+ if (type === 'library') loadLibraryView();
5923
+ else fetchAllTests().then(renderAllTests);
5924
+ }
5925
+
5609
5926
  // ── SAVED URLS ───────────────────────────────────────────────────────────────
5610
5927
  let savedUrlsCache = [];
5611
5928
  let libraryStepsCache = [];
@@ -5744,10 +6061,12 @@ function renderPendingSteps(steps) {
5744
6061
  }
5745
6062
 
5746
6063
  async function loadLibraryView() {
5747
- const [steps, pending] = await Promise.all([
6064
+ const [steps, pending, folders] = await Promise.all([
5748
6065
  fetchLibrarySteps().catch(() => []),
5749
- fetchPendingSteps().catch(() => [])
6066
+ fetchPendingSteps().catch(() => []),
6067
+ fetchFolders().catch(() => [])
5750
6068
  ]);
6069
+ renderFolderTree('library-folder-tree', 'library', selectedLibraryFolder);
5751
6070
  renderPendingSteps(pending);
5752
6071
  populateLibraryTagFilter();
5753
6072
  renderLibrarySteps(steps);
@@ -5880,13 +6199,17 @@ function renderLibrarySteps(steps) {
5880
6199
  const warning = getSelectorWarning(sel);
5881
6200
  const actionColors = { click:'#22d3ee', type:'#a78bfa', check:'#34d399', assert:'#fb923c', select:'#f472b6', scroll:'#6b7280' };
5882
6201
  return `
5883
- <tr data-step-id="${escapeAttr(s.id)}" style="border-bottom:1px solid var(--border);transition:background 0.15s${fragile?';background:rgba(245,158,11,0.04)':''}" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='${fragile?'rgba(245,158,11,0.04)':''}'">
5884
- <td style="padding:12px 16px">
6202
+ <tr data-step-id="${escapeAttr(s.id)}" draggable="true" ondragstart="onItemDragStart(event,'${escapeAttr(s.id)}','library')" style="border-bottom:1px solid var(--border);transition:background 0.15s${fragile?';background:rgba(245,158,11,0.04)':''}" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='${fragile?'rgba(245,158,11,0.04)':''}'" >
6203
+ <td style="padding:12px 8px 12px 16px;color:var(--muted2);cursor:grab;font-size:14px">⠿</td>
6204
+ <td style="padding:12px 8px">
5885
6205
  <input type="checkbox" class="lib-step-checkbox" data-id="${escapeAttr(s.id)}" onchange="onLibStepCheck()" style="width:14px;height:14px">
5886
6206
  </td>
5887
6207
  <td style="padding:12px 20px">
5888
6208
  <div style="font-size:13px;color:var(--text)">${escapeHtml(s.name||'')}</div>
5889
- ${s.tags && s.tags.length ? `<div style="margin-top:4px;display:flex;gap:4px">${s.tags.map(t=>`<span style="font-family:var(--mono);font-size:10px;padding:2px 6px;background:var(--surface2);border-radius:4px;color:var(--muted)">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
6209
+ <div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:3px">
6210
+ ${s.folder ? `<span style="font-family:var(--mono);font-size:10px;padding:2px 6px;background:rgba(34,211,238,0.1);border-radius:4px;color:#22d3ee">📁 ${escapeHtml(s.folder)}</span>` : ''}
6211
+ ${s.tags && s.tags.length ? s.tags.map(t=>`<span style="font-family:var(--mono);font-size:10px;padding:2px 6px;background:var(--surface2);border-radius:4px;color:var(--muted)">${escapeHtml(t)}</span>`).join('') : ''}
6212
+ </div>
5890
6213
  </td>
5891
6214
  <td style="padding:12px 16px;max-width:300px">
5892
6215
  <div style="display:flex;align-items:center;gap:6px">
@@ -5919,8 +6242,10 @@ function onLibStepCheck() {
5919
6242
  const checked = document.querySelectorAll('.lib-step-checkbox:checked');
5920
6243
  const mergeBtn = document.getElementById('btn-merge-steps');
5921
6244
  const deleteBtn = document.getElementById('btn-delete-selected');
6245
+ const moveBtn = document.getElementById('btn-move-to-folder');
5922
6246
  if (mergeBtn) mergeBtn.style.display = checked.length >= 2 ? '' : 'none';
5923
6247
  if (deleteBtn) deleteBtn.style.display = checked.length >= 1 ? '' : 'none';
6248
+ if (moveBtn) moveBtn.style.display = checked.length >= 1 ? '' : 'none';
5924
6249
  }
5925
6250
 
5926
6251
  function toggleSelectAllSteps(checked) {
@@ -6029,7 +6354,8 @@ function filterLibrarySteps() {
6029
6354
  const filtered = libraryStepsCache.filter(s => {
6030
6355
  const matchQ = !q || (s.name||'').toLowerCase().includes(q) || (s.selector||'').toLowerCase().includes(q) || (s.description||'').toLowerCase().includes(q);
6031
6356
  const matchTag = !tag || (s.tags||[]).includes(tag);
6032
- return matchQ && matchTag;
6357
+ const matchFolder = selectedLibraryFolder === null || s.folder === selectedLibraryFolder;
6358
+ return matchQ && matchTag && matchFolder;
6033
6359
  });
6034
6360
  renderLibrarySteps(filtered);
6035
6361
  }