Read-only demo — explore the data freely; editing is disabled. Start free →

Click a collection in the tree to get started

// Track open tabs to prevent duplicates (with full replay state for persistence) var _openTabs = {}; var _STORAGE_KEY = 'ds_workspace_tabs'; var _RESTORING = false; // ========== Persistence Helpers ========== function _getActiveTabLabel() { var wsTab = document.getElementById('ds-workspace'); if (!wsTab) return ''; var activeBtn = wsTab.querySelector(':scope > .wc-tab > .tab-nav > button.tab-link.active'); return activeBtn ? activeBtn.dataset.label : ''; } function _persistTabs() { if (_RESTORING) return; try { var state = { tabs: _openTabs, activeLabel: _getActiveTabLabel(), timestamp: Date.now() }; sessionStorage.setItem(_STORAGE_KEY, JSON.stringify(state)); } catch(e) { /* quota exceeded or serialization issue */ } } function _registerTab(id, label, state) { _openTabs[id] = state; _persistTabs(); // wctabremove cleanup is handled by a global listener at workspace // init time; per-tab listeners would go stale after dsRenameTab. } // ========== Restore Tabs (Lazy Load) ========== function _restoreTabs() { var saved; try { saved = sessionStorage.getItem(_STORAGE_KEY); } catch(e) { return; } if (!saved) return; var state; try { state = JSON.parse(saved); } catch(e) { sessionStorage.removeItem(_STORAGE_KEY); return; } if (!state || !state.tabs) return; // 24-hour expiry if (state.timestamp && (Date.now() - state.timestamp > 86400000)) { sessionStorage.removeItem(_STORAGE_KEY); return; } var ids = Object.keys(state.tabs); if (ids.length === 0) return; _RESTORING = true; try { for (var i = 0; i < ids.length; i++) { _replayTab(ids[i], state.tabs[ids[i]]); } } finally { _RESTORING = false; } _persistTabs(); // Activate the previously active tab (or hash if present, else first) var hashLabel = location.hash ? decodeURIComponent(location.hash.slice(1)) : ''; var activeLabel = hashLabel || state.activeLabel || ''; setTimeout(function() { var wsTab = document.getElementById('ds-workspace'); if (!wsTab) return; var btn = activeLabel ? wsTab.querySelector(':scope > .wc-tab > .tab-nav > button.tab-link[data-label="' + CSS.escape(activeLabel) + '"]') : wsTab.querySelector(':scope > .wc-tab > .tab-nav > button.tab-link'); if (btn) btn.click(); }, 50); } function _replayTab(id, tab) { document.getElementById('ds-empty-state').classList.add('hidden'); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; if (tab.connName) content.dataset.conn = tab.connName; if (tab.dbName) content.dataset.db = tab.dbName; if (tab.collection) content.dataset.coll = tab.collection; if (tab.tabId) content.dataset.tabId = tab.tabId; if (tab.toolId) content.dataset.toolId = tab.toolId; content.setAttribute('hx-get', tab.url); // Lazy load: fire HTMX request only when this tab is first activated content.setAttribute('hx-trigger', 'wctabchange from:closest wc-tab-item once'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); var wsTab = document.getElementById('ds-workspace'); wsTab.addTab(tab.label, content, false); // do NOT auto-activate during restore htmx.process(content); _openTabs[id] = tab; // wctabremove cleanup is handled by a global listener at workspace // init time; per-tab listeners would go stale after dsRenameTab. } // ========== Open Collection Tab ========== window.dsOpenCollection = function(connName, dbName, collection) { document.getElementById('ds-empty-state').classList.add('hidden'); var wsTab = document.getElementById('ds-workspace'); var existingBtns = wsTab.querySelectorAll(':scope > .wc-tab > .tab-nav > button.tab-link'); var count = 0; existingBtns.forEach(function(btn) { var lbl = btn.dataset.label; if (lbl.match(new RegExp('^' + collection + '-\\d+$'))) { count++; } }); var label = collection + '-' + (count + 1); var tabId = connName + '.' + dbName + '.' + label; var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.conn = connName; content.dataset.db = dbName; content.dataset.coll = collection; content.dataset.tabId = tabId; var tabUuid = _newTabUuid(); var url = '/x/data_studio_collection/create?targetConn=' + encodeURIComponent(connName) + '&targetDb=' + encodeURIComponent(dbName) + '&collection=' + encodeURIComponent(collection) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(tabId, label, { type: 'collection', label: label, tabLabel: label, tabUuid: tabUuid, connName: connName, dbName: dbName, collection: collection, tabId: tabId, url: url }); }; // Open a query/pipeline/script tab PRE-FILLED from a _query_history record. // Mirrors dsOpenCollection / dsOpenNewTool but threads &history_id so the // server-side code tab seeds the editors. Guarded everywhere by history_id. window.dsOpenHistory = function(connName, dbName, collection, queryType, historyId) { document.getElementById('ds-empty-state').classList.add('hidden'); var wsTab = document.getElementById('ds-workspace'); var tabUuid = _newTabUuid(); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; var label, url, regType, idKey; if (queryType === 'script') { var n = 0; wsTab.querySelectorAll(':scope > .wc-tab > .tab-nav > button.tab-link').forEach(function(b){ var l = b.dataset.label || ''; if (l.indexOf('Script Runner') === 0) n++; }); label = 'Script Runner-' + (n + 1); idKey = 'script_history_' + tabUuid; url = '/x/data_studio_script/create?history_id=' + encodeURIComponent(historyId) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); regType = 'multi'; } else { var coll = collection || 'query'; var c = 0; wsTab.querySelectorAll(':scope > .wc-tab > .tab-nav > button.tab-link').forEach(function(b){ var l = b.dataset.label || ''; if (l.indexOf(coll + '-') === 0) c++; }); label = coll + '-' + (c + 1); idKey = connName + '.' + dbName + '.' + label; url = '/x/data_studio_collection/create?targetConn=' + encodeURIComponent(connName) + '&targetDb=' + encodeURIComponent(dbName) + '&collection=' + encodeURIComponent(collection) + '&history_id=' + encodeURIComponent(historyId) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); regType = 'collection'; } content.dataset.conn = connName; content.dataset.db = dbName; content.dataset.coll = collection; content.dataset.tabId = idKey; content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(idKey, label, { type: regType, label: label, tabLabel: label, tabUuid: tabUuid, connName: connName, dbName: dbName, collection: collection, historyId: historyId, tabId: idKey, url: url }); }; // ========== Open Lookups tool (single tab — one Lookups tab at a time) ========== window.dsOpenLookups = function(target) { document.getElementById('ds-empty-state').classList.add('hidden'); var wsTab = document.getElementById('ds-workspace'); var label = 'Lookups'; var tabIdBase = '__lookups__'; // De-dupe: if a Lookups tab already exists, focus it (target hint // re-applied via querystring on the fresh load). var existing = _openTabs[tabIdBase]; if (existing) { var existingBtn = wsTab.querySelector('button.tab-link[data-label="' + CSS.escape(existing.label) + '"]'); if (existingBtn) { existingBtn.click(); return; } } var tabUuid = _newTabUuid(); var url = '/x/data_studio_lookups/create?tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); if (target) url += '&target=' + encodeURIComponent(target); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.tabId = tabIdBase; content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(tabIdBase, label, { type: 'lookups', label: label, tabLabel: label, tabUuid: tabUuid, tabId: tabIdBase, target: target || '', url: url }); }; // ========== Delete a document (cross-cluster, with confirm) ========== window.dsDeleteDoc = function(connName, dbName, collection, recordId, docName) { if (window.dsDemoGuard && window.dsDemoGuard()) return; if (!connName || !dbName || !collection || !recordId) { console.warn('[dsDeleteDoc] missing args', connName, dbName, collection, recordId); return; } var label = docName ? String(docName) : recordId.slice(0, 6); wc.Prompt.question({ title: 'Delete document?', text: collection + ' / ' + label, icon: 'warning', confirmButtonText: 'Delete', showCancelButton: true, callback: function(result) { if (!result) return; fetch('/api/delete-records', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connName: connName, dbName: dbName, collName: collection, recordIds: [recordId], }), }).then(function(r) { if (!r.ok) { return r.text().then(function(t) { throw new Error(t || ('HTTP ' + r.status)); }); } return r.json(); }).then(function(j) { htmx.trigger('body', 'showToast', { title: 'Document deleted', icon: 'success' }); }).catch(function(err) { htmx.trigger('body', 'showError', { value: String(err && err.message || err) }); }); }, }); }; // Helper: scope a query to a specific tab's container by tabLabel or tabUuid. window.dsPane = function(tabLabelOrUuid) { if (!tabLabelOrUuid) return null; return document.querySelector( '[data-ds-tab-root][data-ds-tab-label="' + tabLabelOrUuid + '"], ' + '[data-ds-tab-root][data-ds-tab-uuid="' + tabLabelOrUuid + '"]' ); }; window.dsPaneVal = function(tabLabel, name) { var pane = window.dsPane(tabLabel); if (!pane) return ''; var el = pane.querySelector('[name="' + name + '"]'); if (!el) return ''; if (el.editor && typeof el.editor.getValue === 'function') return el.editor.getValue(); if (el.value !== undefined) return el.value; return el.getAttribute('value') || ''; }; // 8-char tab uuid — immutable across rename. Used to suffix per-tab DOM ids // and data-name attributes so they stay stable when state.tabLabel changes. function _newTabUuid() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID().slice(0, 8); } return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } window.dsNewTabUuid = _newTabUuid; // Rename a workspace tab in place. After save, swaps the pane's data-ds-tab-label // attribute (single attribute write — children untouched, so wc-code-mirror / // wc-input / wc-select state is preserved). Updates state, button DOM, hash. // Looks up by tabUuid (preferred) or tabLabel (fallback for older sessions). window.dsRenameTab = function(detail) { if (!detail) return; var oldTabLabel = detail.oldTabLabel; var newSlug = detail.newSlug; var newRecordId = detail.newRecordId; var newDisplayLabel = detail.newDisplayLabel || newSlug; var tabUuid = detail.tabUuid || ''; if (!oldTabLabel || !newSlug) return; var wsTab = document.getElementById('ds-workspace'); if (!wsTab) return; var oldId = null, state = null; if (tabUuid) { for (var id in _openTabs) { var st = _openTabs[id]; if (st && st.tabUuid === tabUuid) { oldId = id; state = st; break; } } } if (!state) { for (var id2 in _openTabs) { var st2 = _openTabs[id2]; if (st2 && (st2.tabLabel === oldTabLabel || st2.label === oldTabLabel)) { oldId = id2; state = st2; break; } } } if (!oldId || !state) return; var oldDisplay = state.label; var btn = wsTab.querySelector(':scope > .wc-tab > .tab-nav > button.tab-link[data-label="' + CSS.escape(oldDisplay) + '"]'); if (!btn) return; var wasActive = btn.classList.contains('active'); // Compute new id: collection tabs key by connName.dbName.label; tools by toolId. var newId = oldId; if (state.type === 'collection' || state.type === 'saved_query') { newId = state.connName + '.' + state.dbName + '.' + newDisplayLabel; if (newId !== oldId && _openTabs[newId]) return; // collision guard } delete _openTabs[oldId]; state.label = newDisplayLabel; state.tabLabel = newSlug; if (state.type === 'collection' || state.type === 'saved_query') { state.tabId = newId; } if (newRecordId) state.recordId = newRecordId; _openTabs[newId] = state; // Update button DOM btn.dataset.label = newDisplayLabel; btn.setAttribute('data-label', newDisplayLabel); for (var i = 0; i < btn.childNodes.length; i++) { var n = btn.childNodes[i]; if (n.nodeType === 3) { n.nodeValue = newDisplayLabel; break; } } var oldClose = btn.querySelector('.tab-close'); if (oldClose) oldClose.remove(); if (wsTab.hasAttribute('removable') && typeof wsTab._createCloseButton === 'function') { btn.appendChild(wsTab._createCloseButton(newDisplayLabel)); } // Update wc-tab-item wrapper label attr var tabItem = wsTab.querySelector(':scope > .wc-tab > .tab-body > wc-tab-item[label="' + CSS.escape(oldDisplay) + '"]'); if (tabItem) tabItem.setAttribute('label', newDisplayLabel); // The key step: swap data-ds-tab-label on the pane container. // Children (form elements, CodeMirrors, etc.) are untouched — state preserved. var pane = wsTab.querySelector('[data-ds-tab-root][data-ds-tab-label="' + oldTabLabel + '"]'); if (!pane && tabUuid) { pane = wsTab.querySelector('[data-ds-tab-root][data-ds-tab-uuid="' + tabUuid + '"]'); } if (pane) { pane.setAttribute('data-ds-tab-label', newSlug); } // Hash update if active if (wasActive) { var encoded = encodeURIComponent(newDisplayLabel); try { history.replaceState(null, '', '#' + encoded); } catch(_) { location.hash = encoded; } } // Refresh state.url so _restoreTabs picks up the saved record on refresh. // If save modal provided a restoreUrl, use it (works for any tool kind). // Otherwise fall back to the collection-tab pattern. if (detail.restoreUrl) { state.url = detail.restoreUrl; } else if (newRecordId && state.type === 'collection') { state.url = '/x/data_studio_collection/create?targetConn=' + encodeURIComponent(state.connName) + '&targetDb=' + encodeURIComponent(state.dbName) + '&collection=' + encodeURIComponent(state.collection) + '&query_id=' + encodeURIComponent(newRecordId) + '&tabLabel=' + encodeURIComponent(newSlug) + '&tabUuid=' + encodeURIComponent(tabUuid || state.tabUuid || ''); state.type = 'saved_query'; state.queryId = newRecordId; } if (typeof _persistTabs === 'function') _persistTabs(); }; // ========== Open Tool Tab ========== window.dsOpenTool = function(toolId, label, url) { if (_openTabs[toolId]) { var wsTab = document.getElementById('ds-workspace'); var btn = wsTab.querySelector('button.tab-link[data-label="' + CSS.escape(label) + '"]'); if (btn) btn.click(); return; } document.getElementById('ds-empty-state').classList.add('hidden'); // Inject tabUuid (and tabLabel if missing) so tab templates can scope // pane lookups even when a generic caller didn't supply them. var tabUuid = _newTabUuid(); var sep = url.indexOf('?') >= 0 ? '&' : '?'; if (url.indexOf('tabLabel=') < 0) { url = url + sep + 'tabLabel=' + encodeURIComponent(label); sep = '&'; } url = url + sep + 'tabUuid=' + encodeURIComponent(tabUuid); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.toolId = toolId; content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); var wsTab = document.getElementById('ds-workspace'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(toolId, label, { type: 'singleton', label: label, tabLabel: label, tabUuid: tabUuid, toolId: toolId, url: url }); }; // ========== Open New Tool Tab (allows multiples) ========== window.dsOpenNewTool = function(prefix, label, url) { document.getElementById('ds-empty-state').classList.add('hidden'); var wsTab = document.getElementById('ds-workspace'); var existingBtns = wsTab.querySelectorAll(':scope > .wc-tab > .tab-nav > button.tab-link'); var count = 0; existingBtns.forEach(function(btn) { var lbl = btn.dataset.label; if (lbl.match(new RegExp('^' + label + '-\\d+$'))) { count++; } }); var displayLabel = label + '-' + (count + 1); var tabLabel = prefix + '_' + (count + 1); var toolId = prefix + '_' + (count + 1); var tabUuid = _newTabUuid(); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.toolId = toolId; var fullUrl = url + (url.indexOf('?') >= 0 ? '&' : '?') + 'tabLabel=' + encodeURIComponent(tabLabel) + '&tabUuid=' + encodeURIComponent(tabUuid); content.setAttribute('hx-get', fullUrl); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); wsTab.addTab(displayLabel, content, true); htmx.process(content); _registerTab(toolId, displayLabel, { type: 'multi', label: displayLabel, prefix: prefix, baseUrl: url, tabLabel: tabLabel, tabUuid: tabUuid, toolId: toolId, url: fullUrl }); }; // ========== Open Saved Query ========== window.dsOpenSavedQuery = function(connName, dbName, collection, queryId, queryType, slug) { var label = slug || collection; var tabId = connName + '.' + dbName + '.' + label; if (_openTabs[tabId]) { var wsTab = document.getElementById('ds-workspace'); var btn = wsTab.querySelector(':scope > .wc-tab > .tab-nav > button.tab-link[data-label="' + CSS.escape(label) + '"]'); if (btn) btn.click(); return; } document.getElementById('ds-empty-state').classList.add('hidden'); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.conn = connName; content.dataset.db = dbName; content.dataset.coll = collection; content.dataset.tabId = tabId; var tabUuid = _newTabUuid(); var url = '/x/data_studio_collection/create?targetConn=' + encodeURIComponent(connName) + '&targetDb=' + encodeURIComponent(dbName) + '&collection=' + encodeURIComponent(collection) + '&query_id=' + encodeURIComponent(queryId) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); var wsTab = document.getElementById('ds-workspace'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(tabId, label, { type: 'saved_query', label: label, tabLabel: label, tabUuid: tabUuid, connName: connName, dbName: dbName, collection: collection, queryId: queryId, tabId: tabId, url: url }); }; // ========== Open Saved Script ========== window.dsOpenSavedVisualQuery = function(id, slug) { var label = slug || 'visual_query'; var toolId = 'visual_query_' + id; if (_openTabs[toolId]) { var wsTab = document.getElementById('ds-workspace'); var btn = wsTab.querySelector('button.tab-link[data-label="' + CSS.escape(label) + '"]'); if (btn) btn.click(); return; } document.getElementById('ds-empty-state').classList.add('hidden'); var tabUuid = _newTabUuid(); var url = '/x/data_studio_visual_query/create?visual_query_id=' + encodeURIComponent(id) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.toolId = toolId; content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); var wsTab = document.getElementById('ds-workspace'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(toolId, label, { type: 'saved_visual_query', label: label, tabLabel: label, tabUuid: tabUuid, toolId: toolId, savedId: id, url: url }); }; window.dsOpenSavedScript = function(scriptId, slug) { var label = slug || 'script'; var toolId = 'script_' + scriptId; if (_openTabs[toolId]) { var wsTab = document.getElementById('ds-workspace'); var btn = wsTab.querySelector('button.tab-link[data-label="' + CSS.escape(label) + '"]'); if (btn) btn.click(); return; } document.getElementById('ds-empty-state').classList.add('hidden'); var tabUuid = _newTabUuid(); var url = '/x/data_studio_script/create?script_id=' + encodeURIComponent(scriptId) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.toolId = toolId; content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); var wsTab = document.getElementById('ds-workspace'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(toolId, label, { type: 'saved_script', label: label, tabLabel: label, tabUuid: tabUuid, toolId: toolId, savedId: scriptId, url: url }); }; // Look up the current script_id for a saved-script tab by tabUuid. Returns // the post-rename recordId, falling back to the original savedId. Used by // data_studio_script's Save button so subsequent saves see the actual id. window.dsLookupScriptIdByTabUuid = function(tabUuid) { if (!tabUuid) return ''; for (var k in _openTabs) { var st = _openTabs[k]; if (st && st.tabUuid === tabUuid) { return st.recordId || st.savedId || ''; } } return ''; }; // ========== Open Saved Compare ========== window.dsOpenSavedCompare = function(id, slug) { var label = slug || 'compare'; var toolId = 'compare_' + id; if (_openTabs[toolId]) { var wsTab = document.getElementById('ds-workspace'); var btn = wsTab.querySelector('button.tab-link[data-label="' + CSS.escape(label) + '"]'); if (btn) btn.click(); return; } document.getElementById('ds-empty-state').classList.add('hidden'); var tabUuid = _newTabUuid(); var url = '/x/data_studio_compare/create?compare_id=' + encodeURIComponent(id) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.toolId = toolId; content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); var wsTab = document.getElementById('ds-workspace'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(toolId, label, { type: 'saved_compare', label: label, tabLabel: label, tabUuid: tabUuid, toolId: toolId, savedId: id, url: url }); }; // ========== Open Saved Bulk Compare ========== window.dsOpenSavedBulkCompare = function(id, slug) { var label = slug || 'bulk_compare'; var toolId = 'bulk_compare_' + id; if (_openTabs[toolId]) { var wsTab = document.getElementById('ds-workspace'); var btn = wsTab.querySelector('button.tab-link[data-label="' + CSS.escape(label) + '"]'); if (btn) btn.click(); return; } document.getElementById('ds-empty-state').classList.add('hidden'); var tabUuid = _newTabUuid(); var url = '/x/data_studio_bulk_compare/create?bulk_compare_id=' + encodeURIComponent(id) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.toolId = toolId; content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); var wsTab = document.getElementById('ds-workspace'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(toolId, label, { type: 'saved_bulk_compare', label: label, tabLabel: label, tabUuid: tabUuid, toolId: toolId, savedId: id, url: url }); }; // ========== Open Saved Index Compare ========== window.dsOpenSavedIndexCompare = function(id, slug) { var label = slug || 'idx-compare'; var toolId = 'idx_compare_' + id; if (_openTabs[toolId]) { var wsTab = document.getElementById('ds-workspace'); var btn = wsTab.querySelector('button.tab-link[data-label="' + CSS.escape(label) + '"]'); if (btn) btn.click(); return; } document.getElementById('ds-empty-state').classList.add('hidden'); var tabUuid = _newTabUuid(); var url = '/x/data_studio_index_compare/create?compare_id=' + encodeURIComponent(id) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.toolId = toolId; content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); var wsTab = document.getElementById('ds-workspace'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(toolId, label, { type: 'saved_index_compare', label: label, tabLabel: label, tabUuid: tabUuid, toolId: toolId, savedId: id, url: url }); }; // ========== Open Saved Import ========== window.dsOpenSavedImport = function(id, slug) { var label = slug || 'import'; var toolId = 'import_' + id; if (_openTabs[toolId]) { var wsTab = document.getElementById('ds-workspace'); var btn = wsTab.querySelector('button.tab-link[data-label="' + CSS.escape(label) + '"]'); if (btn) btn.click(); return; } document.getElementById('ds-empty-state').classList.add('hidden'); var tabUuid = _newTabUuid(); var url = '/x/data_studio_import/create?import_id=' + encodeURIComponent(id) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.toolId = toolId; content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); var wsTab = document.getElementById('ds-workspace'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(toolId, label, { type: 'saved_import', label: label, tabLabel: label, tabUuid: tabUuid, toolId: toolId, savedId: id, url: url }); }; // ========== Open Saved Schema Diff ========== window.dsOpenSavedSchemaDiff = function(id, slug) { var label = slug || 'schema-diff'; var toolId = 'schema_diff_' + id; if (_openTabs[toolId]) { var wsTab = document.getElementById('ds-workspace'); var btn = wsTab.querySelector('button.tab-link[data-label="' + CSS.escape(label) + '"]'); if (btn) btn.click(); return; } document.getElementById('ds-empty-state').classList.add('hidden'); var tabUuid = _newTabUuid(); var url = '/x/data_studio_schema_diff/create?diff_id=' + encodeURIComponent(id) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.toolId = toolId; content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); var wsTab = document.getElementById('ds-workspace'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(toolId, label, { type: 'saved_schema_diff', label: label, tabLabel: label, tabUuid: tabUuid, toolId: toolId, savedId: id, url: url }); }; // ========== Empty State Check ========== function checkEmptyState() { var wsTab = document.getElementById('ds-workspace'); var remaining = wsTab.querySelectorAll('button.tab-link'); if (remaining.length === 0) { document.getElementById('ds-empty-state').classList.remove('hidden'); } } // ========== Tree Filter ========== window.dsFilterTree = function(value) { var tree = document.getElementById('ds-tree'); var items = tree.querySelectorAll('wc-tree-item'); var lower = value.toLowerCase(); if (lower === '') { items.forEach(function(item) { item.style.display = ''; }); return; } // First hide all items.forEach(function(item) { item.style.display = 'none'; }); // Then show matches and their ancestors items.forEach(function(item) { var label = (item.getAttribute('label') || '').toLowerCase(); if (label.indexOf(lower) >= 0) { item.style.display = ''; var parent = item.parentElement.closest('wc-tree-item'); while (parent) { parent.style.display = ''; parent = parent.parentElement.closest('wc-tree-item'); } } }); }; // ========== Keyboard Shortcuts ========== window.dsCloseActiveTab = function() { var wsTab = document.getElementById('ds-workspace'); var activeBtn = wsTab.querySelector(':scope > .wc-tab > .tab-nav > button.tab-link.active'); if (activeBtn) { wsTab.removeTab(activeBtn.dataset.label); } }; window.dsNextTab = function() { var wsTab = document.getElementById('ds-workspace'); var buttons = Array.from(wsTab.querySelectorAll(':scope > .wc-tab > .tab-nav > button.tab-link')); if (buttons.length === 0) return; var activeIdx = buttons.findIndex(function(btn) { return btn.classList.contains('active'); }); var nextIdx = (activeIdx + 1) % buttons.length; buttons[nextIdx].click(); }; window.dsPrevTab = function() { var wsTab = document.getElementById('ds-workspace'); var buttons = Array.from(wsTab.querySelectorAll(':scope > .wc-tab > .tab-nav > button.tab-link')); if (buttons.length === 0) return; var activeIdx = buttons.findIndex(function(btn) { return btn.classList.contains('active'); }); var prevIdx = (activeIdx - 1 + buttons.length) % buttons.length; buttons[prevIdx].click(); }; window.dsNextSubTab = function() { var wsTab = document.getElementById('ds-workspace'); var activeBtn = wsTab.querySelector(':scope > .wc-tab > .tab-nav > button.tab-link.active'); if (!activeBtn) return; var allBtns = Array.from(wsTab.querySelectorAll(':scope > .wc-tab > .tab-nav > button.tab-link')); var idx = allBtns.indexOf(activeBtn); var tabItems = wsTab.querySelectorAll(':scope > .wc-tab > .tab-body > wc-tab-item'); if (!tabItems[idx]) return; var subTab = tabItems[idx].querySelector('wc-tab'); if (!subTab) return; var buttons = Array.from(subTab.querySelectorAll(':scope > .wc-tab > .tab-nav > button.tab-link')); if (buttons.length === 0) return; var activeIdx = buttons.findIndex(function(btn) { return btn.classList.contains('active'); }); var nextIdx = (activeIdx + 1) % buttons.length; buttons[nextIdx].click(); }; window.dsPrevSubTab = function() { var wsTab = document.getElementById('ds-workspace'); var activeBtn = wsTab.querySelector(':scope > .wc-tab > .tab-nav > button.tab-link.active'); if (!activeBtn) return; var allBtns = Array.from(wsTab.querySelectorAll(':scope > .wc-tab > .tab-nav > button.tab-link')); var idx = allBtns.indexOf(activeBtn); var tabItems = wsTab.querySelectorAll(':scope > .wc-tab > .tab-body > wc-tab-item'); if (!tabItems[idx]) return; var subTab = tabItems[idx].querySelector('wc-tab'); if (!subTab) return; var buttons = Array.from(subTab.querySelectorAll(':scope > .wc-tab > .tab-nav > button.tab-link')); if (buttons.length === 0) return; var activeIdx = buttons.findIndex(function(btn) { return btn.classList.contains('active'); }); var prevIdx = (activeIdx - 1 + buttons.length) % buttons.length; buttons[prevIdx].click(); }; // ========== Tree Context Menus ========== document.addEventListener('wctreeitemcontextmenu', function(e) { var d = e.detail; var item = d.element; var icon = item.getAttribute('icon') || ''; var ContextMenu = customElements.get('wc-context-menu'); if (icon === 'server') { // Connection level var connName = d.label; ContextMenu.show(d.x, d.y, [ { label: 'Expand All Databases', icon: 'chevron-down', action: function() { item.expand(); } }, { label: 'Collapse', icon: 'chevron-up', action: function() { item.collapse(); } }, { divider: true }, { label: 'Add Database…', icon: 'plus', action: function() { window.dsOpenAddDatabaseModal(connName); } }, { divider: true }, { label: 'Refresh', icon: 'arrows-rotate', action: function() { window.dsRefreshConnectionNode(connName); } }, { label: 'Refresh Entire Tree', icon: 'arrows-rotate', action: function() { window.dsRefreshTree(); } } ]); } else if (icon === 'database') { // Database level var connItem = item.parentElement.closest('wc-tree-item'); var connName = connItem ? connItem.getAttribute('label') || '' : ''; var dbName = d.label; var cb = window.dsGetClipboard(); var hasClipboard = !!(cb && cb.type === 'copy-collection'); var pasteAsLabel = hasClipboard ? 'Paste "' + cb.srcColl + '" as new collection…' : 'Paste as new collection (nothing copied)'; ContextMenu.show(d.x, d.y, [ { label: 'Expand All Collections', icon: 'chevron-down', action: function() { item.expand(); } }, { label: 'Collapse', icon: 'chevron-up', action: function() { item.collapse(); } }, { divider: true }, { label: pasteAsLabel, icon: 'paste', disabled: !hasClipboard, action: function() { window.dsOpenPasteModal(connName, dbName, ''); } }, { divider: true }, { label: 'Add Collection…', icon: 'plus', action: function() { window.dsAddCollection(connName, dbName); } }, { divider: true }, { label: 'Drop Database', icon: 'trash', action: function() { window.dsDropDatabase(connName, dbName); } }, { divider: true }, { label: 'Refresh', icon: 'arrows-rotate', action: function() { window.dsRefreshDatabaseNode(connName, dbName); } }, { label: 'Refresh Entire Tree', icon: 'arrows-rotate', action: function() { window.dsRefreshTree(); } } ]); } else if (icon === 'table') { // Collection level var connName = ''; var dbName = ''; var dbItem = item.parentElement.closest('wc-tree-item'); if (dbItem) { dbName = dbItem.getAttribute('label') || ''; var connItem = dbItem.parentElement.closest('wc-tree-item'); if (connItem) { connName = connItem.getAttribute('label') || ''; } } var collName = d.label; var cb = window.dsGetClipboard(); var hasClipboard = !!(cb && cb.type === 'copy-collection'); var pasteLabel = hasClipboard ? 'Paste "' + cb.srcColl + '" here…' : 'Paste (nothing copied)'; ContextMenu.show(d.x, d.y, [ { label: 'Query', icon: 'magnifying-glass', action: function() { window.dsOpenCollection(connName, dbName, collName); } }, { label: 'View Indexes', icon: 'bolt', action: function() { window.dsOpenCollectionTab(connName, dbName, collName, 'Indexes'); } }, { label: 'Schema Profile', icon: 'list-ul', action: function() { window.dsOpenCollectionTab(connName, dbName, collName, 'Schema'); } }, { divider: true }, { label: 'Copy', icon: 'copy', action: function() { window.dsCopyCollection(connName, dbName, collName); } }, { label: pasteLabel, icon: 'paste', disabled: !hasClipboard, action: function() { window.dsOpenPasteModal(connName, dbName, collName); } }, { label: 'Normalize Field Order…', icon: 'arrow-down-wide-short', action: function() { window.dsNormalizeFieldOrder(connName, dbName, collName); } }, { label: 'Export as BSON', icon: 'download', action: function() { window.dsExportCollectionBSON(connName, dbName, collName); } }, { divider: true }, { label: 'Expand Fields', icon: 'chevron-down', action: function() { var fieldsItem = item.querySelector('wc-tree-item[label="Fields"]'); if (fieldsItem) fieldsItem.expand(); } }, { label: 'Expand Indexes', icon: 'chevron-down', action: function() { var idxItem = item.querySelector('wc-tree-item[label="Indexes"]'); if (idxItem) idxItem.expand(); } }, { divider: true }, { label: 'Clear Collection…', icon: 'eraser', action: function() { window.dsClearCollection(connName, dbName, collName); } }, { label: 'Drop Collection', icon: 'trash', action: function() { window.dsDropCollection(connName, dbName, collName); } }, { divider: true }, { label: 'Refresh', icon: 'arrows-rotate', action: function() { window.dsRefreshDatabaseNode(connName, dbName); } }, { label: 'Refresh Entire Tree', icon: 'arrows-rotate', action: function() { window.dsRefreshTree(); } } ]); } }); // ========== Tree Helpers ========== window.dsOpenCollectionTab = function(connName, dbName, collection, subTabLabel) { window.dsOpenCollection(connName, dbName, collection); setTimeout(function() { var wsTab = document.getElementById('ds-workspace'); var activeBtn = wsTab.querySelector(':scope > .wc-tab > .tab-nav > button.tab-link.active'); if (!activeBtn) return; var allBtns = Array.from(wsTab.querySelectorAll(':scope > .wc-tab > .tab-nav > button.tab-link')); var idx = allBtns.indexOf(activeBtn); var tabItems = wsTab.querySelectorAll(':scope > .wc-tab > .tab-body > wc-tab-item'); if (!tabItems[idx]) return; var subTab = tabItems[idx].querySelector('wc-tab'); if (!subTab) return; var subBtn = subTab.querySelector(':scope > .wc-tab > .tab-nav > button.tab-link[data-label="' + subTabLabel + '"]'); if (subBtn) subBtn.click(); }, 500); }; // ========== BSON Export ========== // Streams an entire collection as a mongodump-compatible .bson file by // triggering a browser download against /api/export-collection-bson. window.dsExportCollectionBSON = function(connName, dbName, collName) { var url = '/api/export-collection-bson' + '?connName=' + encodeURIComponent(connName) + '&dbName=' + encodeURIComponent(dbName) + '&collection=' + encodeURIComponent(collName); var a = document.createElement('a'); a.href = url; a.download = collName + '.bson'; document.body.appendChild(a); a.click(); document.body.removeChild(a); if (window.wc && wc.Prompt) { wc.Prompt.toast({title: 'Exporting ' + collName + ' as BSON…', icon: 'info'}); } }; // ---------- Tree refresh (state-preserving) ---------- // Surgical: refresh just one connection's databases. Other branches // (and their expansion state) untouched. window.dsRefreshConnectionNode = function(connName) { if (!connName) return; var tree = document.getElementById('ds-tree'); if (!tree) return; var connItem = tree.querySelector('wc-tree-item[icon="server"][label="' + CSS.escape(connName) + '"]'); if (!connItem) return; var children = connItem.querySelector(':scope > .wc-tree-item > .tree-item-children') || connItem.querySelector('.tree-item-children'); if (!children) return; htmx.ajax('GET', '/x/data_studio_tree_databases/create?targetConn=' + encodeURIComponent(connName), { target: children, swap: 'innerHTML', pushUrl: false }); }; // Surgical: refresh just one database's collections. Used post-import // so the new collection shows up without collapsing the whole tree. window.dsRefreshDatabaseNode = function(connName, dbName) { if (!connName || !dbName) return; var tree = document.getElementById('ds-tree'); if (!tree) return; var connItem = tree.querySelector('wc-tree-item[icon="server"][label="' + CSS.escape(connName) + '"]'); if (!connItem) return; var dbItem = connItem.querySelector('wc-tree-item[icon="database"][label="' + CSS.escape(dbName) + '"]'); if (!dbItem) { // Database isn't even rendered yet — refresh the connection level instead. window.dsRefreshConnectionNode(connName); return; } var children = dbItem.querySelector(':scope > .wc-tree-item > .tree-item-children') || dbItem.querySelector('.tree-item-children'); if (!children) return; htmx.ajax('GET', '/x/data_studio_tree_collections/create?targetConn=' + encodeURIComponent(connName) + '&targetDb=' + encodeURIComponent(dbName), { target: children, swap: 'innerHTML', pushUrl: false }); if (typeof dbItem.expand === 'function' && !dbItem.hasAttribute('expanded')) { dbItem.expand(); } }; // Full refresh — captures the currently expanded path tree, re-fetches // the root, and re-expands the same paths once each level's lazy fetch // settles. Used by Ctrl+Shift+R and explicit "Refresh Tree" menu items. window.dsRefreshTree = function(opts) { opts = opts || {}; var tree = document.getElementById('ds-tree'); if (!tree) return; // Capture expansion as label paths from root. var paths = []; if (!opts.skipRestore) { tree.querySelectorAll('wc-tree-item[expanded]').forEach(function(item) { var path = []; var cur = item; while (cur && cur.tagName === 'WC-TREE-ITEM') { var lbl = cur.getAttribute('label'); if (lbl) path.unshift(lbl); cur = cur.parentElement && cur.parentElement.closest('wc-tree-item'); } if (path.length) paths.push(path); }); } htmx.ajax('GET', '/x/data_studio_tree/create', { target: tree, swap: 'innerHTML', pushUrl: false }); if (opts.skipRestore || paths.length === 0) return; // Restore depth-by-depth so EVERY node at depth N has time to lazy- // load its children before we try to find depth-N+1 nodes inside. var maxDepth = 0; paths.forEach(function(p) { if (p.length > maxDepth) maxDepth = p.length; }); var levelDelay = 700; // generous — each level needs HTMX fetch + settle var initialDelay = 250; for (var depth = 1; depth <= maxDepth; depth++) { (function(d) { setTimeout(function() { paths.forEach(function(path) { if (path.length < d) return; var cur = tree; for (var i = 0; i < d; i++) { if (!cur) return; cur = cur.querySelector('wc-tree-item[label="' + CSS.escape(path[i]) + '"]'); } if (cur && typeof cur.expand === 'function' && !cur.hasAttribute('expanded')) { cur.expand(); } }); }, initialDelay + (d - 1) * levelDelay); })(depth); } }; // ========== Delete Saved Item ========== window.dsDeleteSaved = async function(id, name, collection, toolId, connName, dbName) { if (window.dsDemoGuard && window.dsDemoGuard()) return; var confirmed = await wc.Prompt.question({ title: 'Delete?', text: 'Delete "' + name + '"? This cannot be undone.', confirmButtonText: 'Delete', cancelButtonText: 'Cancel' }); if (!confirmed) return; try { var body = new URLSearchParams(); body.append('database_cluster', connName); body.append('database', dbName); body.append('script', 'ctx.DB.DeleteByID(rdx.ConnName, rdx.DBName, "' + collection + '", "' + id + '"); return {deleted: true};'); var response = await fetch('/api/execute-script', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body }); if (response.ok) { wc.Prompt.toast({title: 'Deleted', icon: 'success'}); var container = document.querySelector('[data-tool-id="' + toolId + '"]'); if (container) { var url = container.getAttribute('hx-get') || ''; if (url) htmx.ajax('GET', url, { target: container, swap: 'innerHTML', pushUrl: false }); } } else { wc.Prompt.toast({title: 'Delete failed', icon: 'error'}); } } catch(e) { wc.Prompt.toast({title: 'Error: ' + e.message, icon: 'error'}); } }; // ========== Refresh Saved List ========== window.dsRefreshSavedList = function(toolId) { var container = document.querySelector('[data-tool-id="' + toolId + '"]'); if (container) { var url = container.getAttribute('hx-get') || ''; if (url) htmx.ajax('GET', url, { target: container, swap: 'innerHTML', pushUrl: false }); } }; // ========== Open Saved Reschema ========== window.dsOpenSavedReschema = function(id, slug) { var label = slug || 'reschema'; var toolId = 'reschema_' + id; if (_openTabs[toolId]) { var wsTab = document.getElementById('ds-workspace'); var btn = wsTab.querySelector('button.tab-link[data-label="' + CSS.escape(label) + '"]'); if (btn) btn.click(); return; } document.getElementById('ds-empty-state').classList.add('hidden'); var tabUuid = _newTabUuid(); var url = '/x/data_studio_reschema/create?reschema_id=' + encodeURIComponent(id) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.toolId = toolId; content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); var wsTab = document.getElementById('ds-workspace'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(toolId, label, { type: 'saved_reschema', label: label, tabLabel: label, tabUuid: tabUuid, toolId: toolId, savedId: id, url: url }); }; window.dsOpenSavedMasking = function(id, slug) { var label = slug || 'masking'; var toolId = 'masking_' + id; if (_openTabs[toolId]) { var wsTab = document.getElementById('ds-workspace'); var btn = wsTab.querySelector('button.tab-link[data-label="' + CSS.escape(label) + '"]'); if (btn) btn.click(); return; } document.getElementById('ds-empty-state').classList.add('hidden'); var tabUuid = _newTabUuid(); var url = '/x/data_studio_masking/create?masking_id=' + encodeURIComponent(id) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.toolId = toolId; content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); var wsTab = document.getElementById('ds-workspace'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(toolId, label, { type: 'saved_masking', label: label, tabLabel: label, tabUuid: tabUuid, toolId: toolId, savedId: id, url: url }); }; // ========== Drop Collection / Database ========== window.dsDropCollection = async function(connName, dbName, collName) { if (window.dsDemoGuard && window.dsDemoGuard()) return; var result = await wc.Prompt.fire({ title: 'Drop Collection', html: 'This will permanently delete ' + collName + ' and ALL its data.

Type the collection name to confirm:', input: 'text', inputPlaceholder: collName, confirmButtonText: 'Drop Collection', icon: 'warning', didOpen: function() { var container = document.querySelector('.swal2-container'); if (!container) return; var input = container.querySelector('.swal2-input'); var confirmBtn = container.querySelector('.swal2-confirm'); if (!input || !confirmBtn) return; confirmBtn.disabled = true; input.addEventListener('input', function() { confirmBtn.disabled = (input.value !== collName); }); }, inputValidator: function(value) { if (value !== collName) return 'Name does not match'; } }); if (!result) return; try { var body = new URLSearchParams(); body.append('database_cluster', connName); body.append('database', dbName); body.append('script', 'ctx.DB.RunCommand("' + connName + '", "' + dbName + '", {"drop": "' + collName + '"}); return {dropped: "' + collName + '"};'); var response = await fetch('/api/execute-script', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body }); if (response.ok) { wc.Prompt.toast({title: 'Collection "' + collName + '" dropped', icon: 'success'}); // Surgical: remove just this collection's tree node. var tree = document.getElementById('ds-tree'); var connItem = tree && tree.querySelector('wc-tree-item[icon="server"][label="' + CSS.escape(connName) + '"]'); var dbItem = connItem && connItem.querySelector('wc-tree-item[icon="database"][label="' + CSS.escape(dbName) + '"]'); var collItem = dbItem && dbItem.querySelector('wc-tree-item[icon="table"][label="' + CSS.escape(collName) + '"]'); if (collItem) collItem.remove(); } else { var errText = await response.text(); wc.Prompt.toast({title: 'Error: ' + errText, icon: 'error'}); } } catch(e) { wc.Prompt.toast({title: 'Error: ' + e.message, icon: 'error'}); } }; window.dsClearCollection = async function(connName, dbName, collName) { if (window.dsDemoGuard && window.dsDemoGuard()) return; var result = await wc.Prompt.fire({ title: 'Clear Collection', html: 'This will delete all documents from ' + collName + '. ' + 'Indexes and the collection itself remain.

Type the collection name to confirm:', input: 'text', inputPlaceholder: collName, confirmButtonText: 'Clear Collection', icon: 'warning', inputValidator: function(value) { if (value !== collName) return 'Name does not match'; } }); if (!result) return; try { var body = new URLSearchParams(); body.append('database_cluster', connName); body.append('database', dbName); body.append('script', 'var r = ctx.DB.DeleteMany(' + JSON.stringify(connName) + ', ' + JSON.stringify(dbName) + ', ' + JSON.stringify(collName) + ', {}); ' + 'return {cleared: ' + JSON.stringify(collName) + ', deleted: r};' ); var response = await fetch('/api/execute-script', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body }); if (response.ok) { wc.Prompt.toast({title: 'Collection "' + collName + '" cleared', icon: 'success'}); if (window.dsRefreshDatabaseNode) window.dsRefreshDatabaseNode(connName, dbName); } else { var errText = await response.text(); wc.Prompt.toast({title: 'Error: ' + errText, icon: 'error'}); } } catch(e) { wc.Prompt.toast({title: 'Error: ' + e.message, icon: 'error'}); } }; window.dsDropDatabase = async function(connName, dbName) { if (window.dsDemoGuard && window.dsDemoGuard()) return; var result = await wc.Prompt.fire({ title: 'Drop Database', html: 'This will permanently delete ' + dbName + ' and ALL its collections and data.

Type the database name to confirm:', input: 'text', inputPlaceholder: dbName, confirmButtonText: 'Drop Database', icon: 'warning', didOpen: function() { var container = document.querySelector('.swal2-container'); if (!container) return; var input = container.querySelector('.swal2-input'); var confirmBtn = container.querySelector('.swal2-confirm'); if (!input || !confirmBtn) return; confirmBtn.disabled = true; input.addEventListener('input', function() { confirmBtn.disabled = (input.value !== dbName); }); }, inputValidator: function(value) { if (value !== dbName) return 'Name does not match'; } }); if (!result) return; try { var body = new URLSearchParams(); body.append('database_cluster', connName); body.append('database', dbName); body.append('script', 'ctx.DB.RunCommand("' + connName + '", "' + dbName + '", {"dropDatabase": 1}); return {dropped: "' + dbName + '"};'); var response = await fetch('/api/execute-script', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body }); if (response.ok) { wc.Prompt.toast({title: 'Database "' + dbName + '" dropped', icon: 'success'}); // Surgical: remove just this database's tree node. No full refresh, // no collapse of the rest of the tree. var tree = document.getElementById('ds-tree'); var connItem = tree && tree.querySelector('wc-tree-item[icon="server"][label="' + CSS.escape(connName) + '"]'); var dbItem = connItem && connItem.querySelector('wc-tree-item[icon="database"][label="' + CSS.escape(dbName) + '"]'); if (dbItem) dbItem.remove(); } else { var errText = await response.text(); wc.Prompt.toast({title: 'Error: ' + errText, icon: 'error'}); } } catch(e) { wc.Prompt.toast({title: 'Error: ' + e.message, icon: 'error'}); } }; window.dsOpenAddDatabaseModal = function(connName) { var url = '/x/data_studio_add_database/create' + '?targetConn=' + encodeURIComponent(connName); htmx.ajax('GET', url, { target: '#modal-container', swap: 'innerHTML', indicator: '#content-loader', pushUrl: false }); }; window.dsAddCollection = async function(connName, dbName) { if (window.dsDemoGuard && window.dsDemoGuard()) return; var result = await wc.Prompt.fire({ title: 'Add Collection', html: 'Enter a name for the new collection in ' + dbName + ':', input: 'text', inputPlaceholder: 'collection_name', confirmButtonText: 'Create', icon: 'question', inputValidator: function(value) { var v = (value || '').trim(); if (!v) return 'Collection name is required'; if (v.length > 100) return 'Maximum 100 characters'; if (v.charAt(0) === '$') return 'Cannot start with $'; if (/\0/.test(v)) return 'Cannot contain null character'; if (/^system\./.test(v)) return 'Cannot start with "system."'; } }); if (!result || !result.value) return; var collName = result.value.trim(); try { var script = 'ctx.DB.EnsureCollection(' + JSON.stringify(connName) + ', ' + JSON.stringify(dbName) + ', ' + JSON.stringify(collName) + ');' + ' return {created: ' + JSON.stringify(dbName + '.' + collName) + '};'; var body = new URLSearchParams(); body.append('database_cluster', connName); body.append('database', dbName); body.append('script', script); var response = await fetch('/api/execute-script', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body }); if (response.ok) { wc.Prompt.toast({title: 'Collection "' + collName + '" created', icon: 'success'}); if (window.dsRefreshDatabaseNode) window.dsRefreshDatabaseNode(connName, dbName); } else { var errText = await response.text(); wc.Prompt.toast({title: 'Error: ' + errText, icon: 'error'}); } } catch(e) { wc.Prompt.toast({title: 'Error: ' + e.message, icon: 'error'}); } }; // ========== Open Database Overview ========== window.dsOpenDatabaseOverview = function(connName, dbName) { var label = dbName; var toolId = 'db_' + connName + '_' + dbName; if (_openTabs[toolId]) { var wsTab = document.getElementById('ds-workspace'); var btn = wsTab.querySelector('button.tab-link[data-label="' + CSS.escape(label) + '"]'); if (btn) btn.click(); return; } document.getElementById('ds-empty-state').classList.add('hidden'); var tabUuid = _newTabUuid(); var url = '/x/data_studio_database_overview/create?targetConn=' + encodeURIComponent(connName) + '&targetDb=' + encodeURIComponent(dbName) + '&tabLabel=' + encodeURIComponent(label) + '&tabUuid=' + encodeURIComponent(tabUuid); var content = document.createElement('div'); content.className = 'flex flex-col flex-1 overflow-hidden'; content.dataset.toolId = toolId; content.setAttribute('hx-get', url); content.setAttribute('hx-trigger', 'load'); content.setAttribute('hx-swap', 'innerHTML'); content.setAttribute('hx-indicator', '#content-loader'); content.setAttribute('hx-push-url', 'false'); var wsTab = document.getElementById('ds-workspace'); wsTab.addTab(label, content, true); htmx.process(content); _registerTab(toolId, label, { type: 'db_overview', label: label, tabLabel: label, tabUuid: tabUuid, toolId: toolId, connName: connName, dbName: dbName, url: url }); }; // ========== Copy-Collection Clipboard ========== var DS_CLIPBOARD_KEY = 'ds_clipboard'; window.dsGetClipboard = function() { try { return JSON.parse(sessionStorage.getItem(DS_CLIPBOARD_KEY) || 'null'); } catch (e) { return null; } }; window.dsSetClipboard = function(payload) { if (payload) sessionStorage.setItem(DS_CLIPBOARD_KEY, JSON.stringify(payload)); else sessionStorage.removeItem(DS_CLIPBOARD_KEY); }; window.dsCopyCollection = function(conn, db, coll) { window.dsSetClipboard({ type: 'copy-collection', srcConn: conn, srcDb: db, srcColl: coll }); wc.Prompt.toast({ title: 'Copied', text: conn + ' / ' + db + ' / ' + coll + ' — right-click a collection or database to paste', icon: 'info', timer: 2500 }); }; window.dsNormalizeFieldOrder = function(conn, db, coll) { var url = '/x/data_studio_normalize_field_order/create' + '?targetConn=' + encodeURIComponent(conn) + '&targetDb=' + encodeURIComponent(db) + '&collection=' + encodeURIComponent(coll); htmx.ajax('GET', url, { target: '#modal-container', swap: 'innerHTML', indicator: '#content-loader', pushUrl: false }); }; window.dsOpenPasteModal = function(tgtConn, tgtDb, tgtColl) { var cb = window.dsGetClipboard(); if (!cb || cb.type !== 'copy-collection') { wc.Prompt.toast({ title: 'Nothing to paste — right-click a collection and choose Copy first', icon: 'warning' }); return; } var url = '/x/data_studio_copy_collection/create' + '?srcConn=' + encodeURIComponent(cb.srcConn) + '&srcDb=' + encodeURIComponent(cb.srcDb) + '&srcColl=' + encodeURIComponent(cb.srcColl) + '&tgtConn=' + encodeURIComponent(tgtConn) + '&tgtDb=' + encodeURIComponent(tgtDb); if (tgtColl) url += '&tgtColl=' + encodeURIComponent(tgtColl); htmx.ajax('GET', url, { target: '#modal-container', swap: 'innerHTML', indicator: '#content-loader', pushUrl: false }); }; // Click a Save button by id, but only if it's currently visible (tab-safe). // Used by every Ctrl+S / Cmd+S hotkey in DataStudio's tab-embedded editors // so multi-tab workspaces don't all fire on one keypress. window.dsSaveByIdIfVisible = function(id) { var btn = document.getElementById(id); var modal = document.getElementById('modal-container'); var kids = modal ? modal.children.length : 'no container'; if (!btn) return; if (btn.offsetParent === null) return; if (modal && modal.children.length > 0) return; btn.click(); }; // ========== Init: bind workspace listeners + restore tabs ========== (function _initDataStudio() { var wsTab = document.getElementById('ds-workspace'); if (!wsTab) return; // Track active tab in URL hash + persist on every switch wsTab.addEventListener('wctabchange', function(e) { if (_RESTORING) return; // Inner wc-tab elements (Query/Parameters/Indexes/Schema, Results/ // Explain/Editor, etc.) bubble wctabchange up to this listener. Only // react when the event actually originated from the workspace tab — // otherwise a sub-tab click would clobber the URL hash. if (e.target.closest('wc-tab') !== wsTab) return; if (e.detail && e.detail.label) { var encoded = encodeURIComponent(e.detail.label); try { history.replaceState(null, '', '#' + encoded); } catch(_) { location.hash = encoded; } _persistTabs(); } // CodeMirror viewport fix: when a tab body was display:none and is // now re-shown, CM's internal char-width / gutter geometry is stale // and renders with the first column clipped until it refreshes. // Deferred via double-rAF so the layout is settled before we measure. requestAnimationFrame(function() { requestAnimationFrame(function() { var cms = wsTab.querySelectorAll('wc-code-mirror'); cms.forEach(function(cm) { if (cm.offsetParent === null) return; // still hidden — skip if (cm.editor && typeof cm.editor.refresh === 'function') { cm.editor.refresh(); } else if (typeof cm.display === 'function') { cm.display(); // wc-code-mirror fallback API } }); }); }); }); // dsGlobalWctabremoveHandler — single workspace-level cleanup. Per-tab // listeners went stale after dsRenameTab updated tab labels; this one // looks up state by current label at fire time, so it stays correct. wsTab.addEventListener('wctabremove', function(e) { if (!e.detail || !e.detail.label) return; var closedLabel = e.detail.label; // Find the openTabs entry whose live state.label matches the closed // tab's label (state.label is updated by dsRenameTab on save). for (var k in _openTabs) { var st = _openTabs[k]; if (st && st.label === closedLabel) { delete _openTabs[k]; break; } } checkEmptyState(); _persistTabs(); // Clear URL hash if no tabs remain. var remaining = wsTab.querySelectorAll(':scope > .wc-tab > .tab-nav > button.tab-link'); if (remaining.length === 0) { try { history.replaceState(null, '', location.pathname + location.search); } catch(_) {} } }); // Wait for wc-tab's internal .tab-nav/.tab-body to render before restoring. // wc-tab extends WcBaseComponent which exposes a `ready` Promise that // resolves after _render() creates the inner structure. if (wsTab.ready && typeof wsTab.ready.then === 'function') { wsTab.ready.then(_restoreTabs); } else { _restoreTabs(); } })(); studioHandleDeepLink({ test_data: function(qs) { window.dsOpenNewTool('test_data', 'Test Data', '/x/data_studio_test_data_gen/create'); }, script: function(qs) { window.dsOpenSavedScript(qs.get('id'), qs.get('slug') || ''); }, compare: function(qs) { window.dsOpenSavedCompare(qs.get('id'), qs.get('slug') || ''); }, visualQuery: function(qs) { window.dsOpenSavedVisualQuery(qs.get('id'), qs.get('slug') || ''); }, indexCompare: function(qs) { window.dsOpenSavedIndexCompare(qs.get('id'), qs.get('slug') || ''); }, import: function(qs) { window.dsOpenSavedImport(qs.get('id'), qs.get('slug') || ''); }, schemaDiff: function(qs) { window.dsOpenSavedSchemaDiff(qs.get('id'), qs.get('slug') || ''); }, reschema: function(qs) { window.dsOpenSavedReschema(qs.get('id'), qs.get('slug') || ''); }, masking: function(qs) { window.dsOpenSavedMasking(qs.get('id'), qs.get('slug') || ''); }, bulkCompare: function(qs) { window.dsOpenSavedBulkCompare(qs.get('id'), qs.get('slug') || ''); }, query: function(qs) { window.dsOpenSavedQuery( qs.get('conn') || '', qs.get('db') || '', qs.get('coll') || '', qs.get('id'), qs.get('type') || '', qs.get('slug') || '' ); }, lookups: function(qs) { window.dsOpenLookups(qs.get('target') || ''); }, collection: function(qs) { window.dsOpenCollection(qs.get('conn') || '', qs.get('db') || '', qs.get('coll') || ''); }, database: function(qs) { window.dsOpenDatabaseOverview(qs.get('conn') || '', qs.get('db') || ''); } });
// ===== DataStudio AI auto-fill ===== // wc-ai-bot (bot-id="ds-ai-assist") generates a /query result and fires // wcbotresponsereceived. Drop it into the ACTIVE collection tab's editors. // No auto-run. Prefers detail.parsed; falls back to parsing fenced blocks. (function () { var FENCE = String.fromCharCode(96, 96, 96); var NL = String.fromCharCode(10); function block(text, lang) { if (!text) return null; var open = text.indexOf(FENCE + lang); if (open < 0) return null; var bodyStart = text.indexOf(NL, open); if (bodyStart < 0) return null; var close = text.indexOf(FENCE, bodyStart); if (close < 0) return null; return text.substring(bodyStart + 1, close).trim(); } function parseParts(detail) { var p = detail && detail.parsed; if (p && (p.pipeline || p.query || p.projection || p.sort)) { if (p.pipeline) return { kind: "pipeline", pipeline: p.pipeline }; return { kind: "find", query: p.query || "{}", projection: p.projection || "", sort: p.sort || "" }; } var r = detail && detail.response; if (!r) return null; var pipeline = block(r, "pipeline"); if (pipeline) return { kind: "pipeline", pipeline: pipeline }; var query = block(r, "query"); if (query) return { kind: "find", query: query, projection: block(r, "projection") || "", sort: block(r, "sort") || "" }; return null; } function activePane() { var panes = document.querySelectorAll("[data-ds-tab-root][data-ds-tab-uuid]"); for (var i = 0; i < panes.length; i++) { if (panes[i].offsetParent !== null) return panes[i]; } return null; } function setCM(pane, name, val) { var cm = pane.querySelector("wc-code-mirror[name=" + JSON.stringify(name) + "]"); if (!cm) return; if (cm.editor) { cm.editor.setValue(val); if (cm.editor.refresh) cm.editor.refresh(); } else { cm.setAttribute("value", val); } } function setMode(pane, uuid, mode) { var input = pane.querySelector('wc-input[name^="ds_query_type"]'); if (input) { input.value = mode; input.dispatchEvent(new Event("change", { bubbles: true })); } // mirror the hyperscript row show/hide as a backstop var ids = { q: "ds-query-row-" + uuid, f: "ds-query-fields-" + uuid, p: "ds-pipeline-row-" + uuid, s: "ds-sql-row-" + uuid }; ["q","f","p","s"].forEach(function (k) { var el = pane.querySelector("#" + ids[k]); if (el) el.classList.add("hidden"); }); if (mode === "pipeline") { var pp = pane.querySelector("#" + ids.p); if (pp) pp.classList.remove("hidden"); } else { var qr = pane.querySelector("#" + ids.q); if (qr) qr.classList.remove("hidden"); var qf = pane.querySelector("#" + ids.f); if (qf) qf.classList.remove("hidden"); } requestAnimationFrame(function () { requestAnimationFrame(function () { pane.querySelectorAll("wc-code-mirror").forEach(function (cm) { if (cm.display) cm.display(); }); }); }); } document.addEventListener("wcbotresponsereceived", function (e) { try { var parts = parseParts(e.detail || {}); if (!parts) return; // plain chat (not a /query result) — leave it in the bubble var pane = activePane(); if (!pane) { if (window.wc && wc.Notify) wc.Notify.showWarning("Open a collection tab to apply the query."); return; } var uuid = pane.getAttribute("data-ds-tab-uuid"); setMode(pane, uuid, parts.kind === "pipeline" ? "pipeline" : "query"); if (parts.kind === "pipeline") { setCM(pane, "ds_pipeline", parts.pipeline); } else { setCM(pane, "ds_query", parts.query || "{}"); if (parts.projection) setCM(pane, "ds_projection", parts.projection); if (parts.sort) setCM(pane, "ds_sort", parts.sort); } if (window.wc && wc.Notify) wc.Notify.showInfo("Query filled from DataStudio AI — click Run to execute."); } catch (err) { /* never break the page on a bot message */ } }, true); })();
Otto

👋 Hi, I'm Otto. Ask me anything about this demo data — I query it live. Try:

  • How many pending orders do we have?
  • Top 5 products by price
  • Which customers have the highest lifetime value?