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.
- package/cli/commands/dashboard.js +81 -0
- package/package.json +1 -1
- package/web/app/index.html +355 -29
|
@@ -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
package/web/app/index.html
CHANGED
|
@@ -1531,22 +1531,36 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
|
|
|
1531
1531
|
</div>
|
|
1532
1532
|
</div>
|
|
1533
1533
|
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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:
|
|
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-
|
|
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
|
-
<!--
|
|
1614
|
-
<div style="display:
|
|
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
|
-
|
|
1622
|
-
<div
|
|
1623
|
-
<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
|
|
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
|
-
|
|
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
|
-
|
|
6357
|
+
const matchFolder = selectedLibraryFolder === null || s.folder === selectedLibraryFolder;
|
|
6358
|
+
return matchQ && matchTag && matchFolder;
|
|
6033
6359
|
});
|
|
6034
6360
|
renderLibrarySteps(filtered);
|
|
6035
6361
|
}
|