diff --git a/app.py b/app.py
index ba877de..63337fc 100644
--- a/app.py
+++ b/app.py
@@ -17,10 +17,10 @@
import shutil
import logging
import urllib.error
-from datetime import datetime, timezone
-from pathlib import Path
-from flask import Flask, request, jsonify, send_from_directory, Response
-from werkzeug.exceptions import BadRequest
+from datetime import datetime, timezone
+from pathlib import Path
+from flask import Flask, request, jsonify, send_from_directory, Response
+from werkzeug.exceptions import BadRequest
# Setup logger for DevShell backend logging
logging.basicConfig(level=logging.INFO)
@@ -2397,18 +2397,19 @@ def parse_script_metadata(filepath):
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
- if line.startswith("# name:"):
- name_val = line[7:].strip()
- if name_val:
- metadata["name"] = name_val
- elif line.startswith("# desc:"):
- metadata["desc"] = line[7:].strip()
- elif line.startswith("# tag:"):
- metadata["tag"] = line[6:].strip()
- elif line.startswith("# url:"):
- metadata["url"] = line[6:].strip()
- elif not line.startswith("#") and line:
+ if line.startswith('# name:'):
+ name_val = line[7:].strip()
+ if name_val:
+ metadata['name'] = name_val
+ elif line.startswith('# desc:'):
+ metadata['desc'] = line[7:].strip()
+ elif line.startswith('# tag:'):
+ metadata['tag'] = line[6:].strip()
+ elif line.startswith('# url:'):
+ metadata['url'] = line[6:].strip()
+ elif not line.startswith('#') and line:
break
+
except Exception: # nosec B110
pass
return metadata
@@ -2450,10 +2451,10 @@ def get_all_scripts():
# ─── Security Enhancements ──────────────────────────────────────────
@app.before_request
-def enforce_security():
- from flask import abort
- from urllib.parse import urlparse
-
+def enforce_security():
+ from flask import abort
+ from urllib.parse import urlparse
+
# 1. Host Validation (prevents DNS Rebinding)
host_only = request.host.split(':')[0]
if host_only not in ('127.0.0.1', 'localhost'):
@@ -2479,22 +2480,22 @@ def is_valid_local(url):
abort(403)
else:
# Reject if neither is present and request is from a browser
- user_agent = request.headers.get('User-Agent', '')
- if any(b in user_agent for b in ['Mozilla', 'Chrome', 'Safari', 'Edge']):
- abort(403)
-
- # 3. JSON body validation. Many API handlers safely default missing JSON to
- # an empty payload, but malformed JSON should fail before route logic runs.
- if request.method in ['POST', 'PUT', 'DELETE', 'PATCH'] and request.is_json:
- try:
- request.get_json(silent=False)
- except BadRequest:
- return jsonify({
- "success": False,
- "error": "Invalid JSON payload",
- }), 400
-
-# ─── Routes ───────────────────────────────────────────────────────
+ user_agent = request.headers.get('User-Agent', '')
+ if any(b in user_agent for b in ['Mozilla', 'Chrome', 'Safari', 'Edge']):
+ abort(403)
+
+ # 3. JSON body validation. Many API handlers safely default missing JSON to
+ # an empty payload, but malformed JSON should fail before route logic runs.
+ if request.method in ['POST', 'PUT', 'DELETE', 'PATCH'] and request.is_json:
+ try:
+ request.get_json(silent=False)
+ except BadRequest:
+ return jsonify({
+ "success": False,
+ "error": "Invalid JSON payload",
+ }), 400
+
+# ─── Routes ───────────────────────────────────────────────────────
@app.route("/")
diff --git a/ui/app.js b/ui/app.js
index bf7725b..f75230b 100644
--- a/ui/app.js
+++ b/ui/app.js
@@ -35,6 +35,7 @@ const API = {
let state = {
scripts: {},
activeScript: null,
+ expandedCategories: new Set(['Favorites']),
lockTarget: null,
expandedCategories: new Set(),
expandedRoot: true,
@@ -79,6 +80,7 @@ let state = {
reliabilityFilter: 'all',
reliabilitySearch: '',
reliabilityDiagnostics: null,
+ terminalStatuses: {},
};
const unlockCredentials = new Map();
@@ -87,6 +89,96 @@ function isScriptUnlocked(relPath) {
return !!state.unlockedScripts[relPath];
}
+function getTerminalDisplayName(termId) {
+ termId = Number(termId);
+ const index = state.terminals.indexOf(termId);
+ return index !== -1 ? `Terminal ${index + 1}` : `Terminal ${termId}`;
+}
+
+function renumberTabs() {
+ state.terminals.forEach((id, index) => {
+ const tabBtn = document.getElementById(`tab-btn-${id}`) || document.querySelector(`.cli-tab[data-id="${id}"]`);
+ if (tabBtn) {
+ const spans = tabBtn.getElementsByTagName('span');
+ for (let span of spans) {
+ if (span.textContent.trim().startsWith('Terminal')) {
+ span.textContent = getTerminalDisplayName(id);
+ break;
+ }
+ }
+ }
+ });
+}
+
+function updateTabStatusIndicator(termId) {
+ termId = Number(termId);
+ const indicator = document.getElementById(`tab-status-${termId}`);
+ if (!indicator) return;
+
+ const status = state.terminalStatuses[termId];
+ if (status === 'running') {
+ indicator.innerHTML = ``;
+ } else if (status === 'success') {
+ indicator.innerHTML = `✓`;
+ } else if (status === 'failed') {
+ indicator.innerHTML = `✗`;
+ } else {
+ indicator.innerHTML = '';
+ }
+}
+
+function showCustomConfirm(message, title = 'Confirmation') {
+ return new Promise((resolve) => {
+ const overlay = document.getElementById('confirm-modal-overlay');
+ const titleEl = document.getElementById('confirm-modal-title');
+ const messageEl = document.getElementById('confirm-modal-message');
+ const confirmBtn = document.getElementById('confirm-modal-confirm');
+ const cancelBtn = document.getElementById('confirm-modal-cancel');
+ const closeBtn = document.getElementById('confirm-modal-close');
+
+ if (!overlay || !titleEl || !messageEl || !confirmBtn || !cancelBtn || !closeBtn) {
+ resolve(confirm(message));
+ return;
+ }
+
+ titleEl.textContent = title;
+ messageEl.textContent = message;
+
+ overlay.classList.add('active');
+ confirmBtn.focus();
+
+ const cleanup = (value) => {
+ overlay.classList.remove('active');
+ confirmBtn.removeEventListener('click', onConfirm);
+ cancelBtn.removeEventListener('click', onCancel);
+ closeBtn.removeEventListener('click', onCancel);
+ document.removeEventListener('keydown', onKeyDown);
+ resolve(value);
+ };
+
+ const onConfirm = () => cleanup(true);
+ const onCancel = () => cleanup(false);
+ const onKeyDown = (e) => {
+ if (e.key === 'Escape') {
+ onCancel();
+ }
+ };
+
+ confirmBtn.addEventListener('click', onConfirm);
+ cancelBtn.addEventListener('click', onCancel);
+ closeBtn.addEventListener('click', onCancel);
+ document.addEventListener('keydown', onKeyDown);
+ });
+}
+
+function changeTerminalFontSize(delta) {
+ let terminalFontSize = parseInt(localStorage.getItem('terminal-font-size')) || 13;
+ terminalFontSize = Math.max(9, Math.min(24, terminalFontSize + delta));
+ localStorage.setItem('terminal-font-size', terminalFontSize);
+ document.documentElement.style.setProperty('--terminal-font-size', `${terminalFontSize}px`);
+}
+
+
function getUnlockPassword(relPath) {
return unlockCredentials.get(relPath) || '';
}
@@ -177,6 +269,77 @@ if (!window.__devshell_lifecycle_registered) {
}
// ─── Init ──────────────────────────────────────────────────
+const ANALYTICS_TEMPLATE = `
+
+
+
+
0
+
+
All executions
+
+
+
+
+
+
0s
+
+
Latency average
+
+
+
+
+
Top Executed Scripts
+
+
+
+
+
+`;
+
+const ANALYTICS_EMPTY_TEMPLATE = `
+
+
+
No Execution History
+
Analytics will appear here after you run commands or execute scripts.
+
+`;
+
async function openAnalytics() {
try {
const res = await fetch('/api/history/analytics');
@@ -188,32 +351,60 @@ async function openAnalytics() {
}
const summary = data.summary;
+ const total = summary.total || 0;
+
+ const contentEl = document.getElementById('analytics-content');
+ if (!contentEl) return;
- document.getElementById('analytics-total').textContent = summary.total;
+ if (total === 0) {
+ contentEl.innerHTML = ANALYTICS_EMPTY_TEMPLATE;
+ document.getElementById('analytics-modal-overlay').classList.add('active');
+ return;
+ }
+
+ contentEl.innerHTML = ANALYTICS_TEMPLATE;
+
+ const successRate = total > 0 ? Math.round((summary.successful / total) * 100) : 0;
+ const failedRate = total > 0 ? Math.round((summary.failed / total) * 100) : 0;
+
+ document.getElementById('analytics-total').textContent = total;
document.getElementById('analytics-success').textContent = summary.successful;
document.getElementById('analytics-failed').textContent = summary.failed;
document.getElementById('analytics-avg').textContent = `${summary.avg_duration}s`;
+ const successFill = document.getElementById('analytics-success-fill');
+ const successRateEl = document.getElementById('analytics-success-rate');
+ if (successFill) successFill.style.width = `${successRate}%`;
+ if (successRateEl) successRateEl.textContent = `${successRate}% rate`;
+
+ const failedFill = document.getElementById('analytics-failed-fill');
+ const failedRateEl = document.getElementById('analytics-failed-rate');
+ if (failedFill) failedFill.style.width = `${failedRate}%`;
+ if (failedRateEl) failedRateEl.textContent = `${failedRate}% rate`;
+
document.getElementById('analytics-top-scripts').innerHTML =
data.top_scripts.map(([name, count]) => `
- ${escapeHtml(name)} — ${count} runs
+ ${escapeHtml(name)}
+ ${count} ${count === 1 ? 'run' : 'runs'}
- `).join('');
+ `).join('') || 'No execution data available.
';
document.getElementById('analytics-slowest').innerHTML =
data.slowest.map(entry => `
- ${escapeHtml(entry.display_name)} — ${entry.duration_seconds}s
+ ${escapeHtml(entry.display_name)}
+ ${entry.duration_seconds}s
- `).join('');
+ `).join('') || 'No slow executions recorded.
';
document.getElementById('analytics-failures').innerHTML =
data.recent_failures.map(entry => `
- ${escapeHtml(entry.display_name)} — Exit ${entry.exit_code}
+ ${escapeHtml(entry.display_name)}
+ Exit ${entry.exit_code}
- `).join('');
+ `).join('') || 'No recent failures recorded.
';
document.getElementById('analytics-modal-overlay').classList.add('active');
@@ -884,6 +1075,9 @@ async function loadCommandHistory() {
}
document.addEventListener('DOMContentLoaded', async () => {
+ let terminalFontSize = parseInt(localStorage.getItem('terminal-font-size')) || 13;
+ document.documentElement.style.setProperty('--terminal-font-size', `${terminalFontSize}px`);
+
await loadScripts();
await loadCommandHistory();
bindEvents();
@@ -994,6 +1188,7 @@ async function fetchScriptContent(relPath, password = '') {
}
function getTerminalBody(termId = state.activeTerminalId) {
+ termId = Number(termId);
return document.getElementById(`terminal-body-${termId}`)
|| (termId === 1 ? document.getElementById('terminal-body') : null);
}
@@ -1148,6 +1343,8 @@ async function executeScriptWithArguments(relPath, argumentsText) {
controller: controller
};
updateRunButton();
+ state.terminalStatuses[termId] = 'running';
+ updateTabStatusIndicator(termId);
if (termId === state.activeTerminalId) {
runStatus.textContent = 'Executing...';
@@ -1252,6 +1449,8 @@ async function executeScriptWithArguments(relPath, argumentsText) {
}
} else if (data.type === 'aborted') {
receivedTerminalEvent = true;
+ state.terminalStatuses[termId] = 'failed';
+ updateTabStatusIndicator(termId);
appendToCli(data.content, 'error', termId);
if (typeof DebuggerConsole !== 'undefined') {
DebuggerConsole.addEntry('error', `Script aborted (ID: ${data.run_id})`, 'script');
@@ -1263,6 +1462,8 @@ async function executeScriptWithArguments(relPath, argumentsText) {
} else if (data.type === 'metrics') {
receivedTerminalEvent = true;
if (data.success) {
+ state.terminalStatuses[termId] = 'success';
+ updateTabStatusIndicator(termId);
appendToCli(`Script completed (Exit code: ${data.exit_code})`, 'success', termId);
if (typeof DebuggerConsole !== 'undefined') {
DebuggerConsole.addEntry('info', `✓ Script completed — exit code: ${data.exit_code} | time: ${data.resources?.execution_time_formatted || ''} | cpu: ${data.resources?.cpu_percent || 0}% | mem: ${data.resources?.memory_used_mb || 0}MB`, 'metrics');
@@ -1285,6 +1486,8 @@ async function executeScriptWithArguments(relPath, argumentsText) {
}, 3000);
}
} else {
+ state.terminalStatuses[termId] = 'failed';
+ updateTabStatusIndicator(termId);
appendToCli(`Script failed (Exit code: ${data.exit_code})`, 'error', termId);
if (typeof DebuggerConsole !== 'undefined') {
DebuggerConsole.addEntry('error', `✗ Script failed — exit code: ${data.exit_code}`, 'metrics');
@@ -1324,6 +1527,8 @@ async function executeScriptWithArguments(relPath, argumentsText) {
const running = state.runningScripts[termId];
if (!receivedTerminalEvent && running && !running.abortRequested) {
+ state.terminalStatuses[termId] = 'failed';
+ updateTabStatusIndicator(termId);
appendToCli('Connection to script stream lost unexpectedly.', 'error', termId);
if (termId === state.activeTerminalId) {
runStatus.textContent = 'Disconnected';
@@ -1338,6 +1543,8 @@ async function executeScriptWithArguments(relPath, argumentsText) {
}
}
} catch (err) {
+ state.terminalStatuses[termId] = 'failed';
+ updateTabStatusIndicator(termId);
if (err.name === 'AbortError') {
appendToCli('Script run aborted.', 'system', termId);
if (termId === state.activeTerminalId) {
@@ -1675,7 +1882,7 @@ async function saveScript(category, filename, content) {
}
async function deleteScript(relPath) {
- if (!confirm('Are you sure you want to delete this script permanently?')) return;
+ if (!await showCustomConfirm('Are you sure you want to delete this script permanently?', 'Delete Script')) return;
try {
const res = await fetch(API.delete, {
method: 'DELETE',
@@ -1904,8 +2111,9 @@ async function executePR(relPath, branch, message, repoUrl) {
// Offer PR page opening
if (
- confirm(
- `Successfully pushed to branch '${data.branch}'.\n\nWould you like to open the Pull Request page on GitHub?`
+ await showCustomConfirm(
+ `Successfully pushed to branch '${data.branch}'.\n\nWould you like to open the Pull Request page on GitHub?`,
+ 'PR Created'
)
) {
window.open(data.pr_url, '_blank', 'noopener,noreferrer');
@@ -2177,7 +2385,7 @@ function appendToCli(text, className = '', termId = state.activeTerminalId) {
function clearCli() {
const termBody = getTerminalBody(state.activeTerminalId);
if (termBody) {
- termBody.innerHTML = '$ Terminal cleared.
';
+ termBody.innerHTML = '$Terminal cleared.
Ready for new commands.
';
}
document.getElementById('run-status').textContent = '';
document.getElementById('run-status').className = 'run-status';
@@ -2190,207 +2398,7 @@ function clearCli() {
}
-// ─── Session Persistence ──────────────────────────────────
-
-async function saveSession() {
- const sessionData = {
- sessionId: state.sessionId || generateUUID(),
- timestamp: Date.now(),
-
- terminals: state.terminals.map(id => {
- const body =
- document.getElementById(`terminal-body-${id}`) ||
- (id === 1
- ? document.getElementById('terminal-body')
- : null);
-
- if (!body) return null;
-
- const lines = Array.from(
- body.querySelectorAll('.cli-output-block')
- )
- .slice(-100)
- .map(el => ({
- text: el.textContent,
- className: el.className.replace(
- 'cli-output-block ',
- ''
- )
- }));
-
- return {
- id,
- lines
- };
- }).filter(t => t !== null),
-
- activeTerminalId: state.activeTerminalId,
- nextTerminalId: state.nextTerminalId,
-
- cmdHistory: state.cmdHistory,
- cmdHistoryIndex: state.cmdHistoryIndex,
-
- unlockedScripts: serializeUnlockedScripts()
- };
-
- try {
- await fetch('/api/sessions/save', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- session: sessionData
- })
- });
-
- state.sessionId = sessionData.sessionId;
- state.lastSaveTimestamp = Date.now();
-
- } catch (e) {
- console.error('Failed to save session:', e);
- }
-}
-
-
-function generateUUID() {
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
- .replace(/[xy]/g, c => {
- const r = Math.random() * 16 | 0;
- const v = c === 'x'
- ? r
- : (r & 0x3 | 0x8);
-
- return v.toString(16);
- });
-}
-
-
-// let saveSessionTimeout = null;
-
-function saveSessionDebounced() {
- if (saveSessionTimeout) {
- clearTimeout(saveSessionTimeout);
- }
-
- saveSessionTimeout = setTimeout(() => {
- saveSession();
- }, 2000);
-}
-
-
-async function restoreSession() {
- try {
- const res = await fetch('/api/sessions/restore');
- const data = await res.json();
-
- if (!data.success || !data.session) {
- return;
- }
-
- const session = data.session;
-
- state.sessionId = session.sessionId || null;
-
- const terminalIds = session.terminals?.map(t => t.id);
- state.terminals = terminalIds?.length ? terminalIds : [1];
-
- state.activeTerminalId =
- session.activeTerminalId || 1;
-
- state.nextTerminalId =
- Math.max(...state.terminals) + 1;
-
- state.cmdHistory =
- session.cmdHistory || [];
-
- state.cmdHistoryIndex =
- session.cmdHistoryIndex || -1;
-
- restoreUnlockedScripts(session.unlockedScripts);
-
- const existingTabs =
- document.querySelectorAll('.cli-tab');
-
- existingTabs.forEach(tab => {
- if (tab.id !== 'tab-btn-1') {
- tab.remove();
- }
- });
- const existingBodies =
- document.querySelectorAll('.cli-body');
-
- existingBodies.forEach(body => {
- if (body.id !== 'terminal-body') {
- body.remove();
- }
- });
-
- for (const term of session.terminals || []) {
-
- if (term.id !== 1) {
- // Create terminal DOM directly with the saved ID
- // instead of calling addTerminal() which would
- // corrupt state.nextTerminalId and state.terminals
- const tabsContainer = document.getElementById('cli-tabs');
- const tabBtn = document.createElement('div');
- tabBtn.className = 'cli-tab';
- tabBtn.id = `tab-btn-${term.id}`;
- tabBtn.innerHTML = `
-
-
-
-
-
- Terminal ${term.id}
- `;
- tabBtn.onclick = () => switchTerminal(term.id);
- tabsContainer.insertBefore(tabBtn, document.getElementById('btn-add-tab'));
-
- const bodyContainer = document.createElement('div');
- bodyContainer.className = 'cli-body';
- bodyContainer.setAttribute('role', 'log');
- bodyContainer.setAttribute('aria-live', 'polite');
- bodyContainer.id = `terminal-body-${term.id}`;
- bodyContainer.style.display = 'none';
-
- document.getElementById('cli-area').insertBefore(
- bodyContainer,
- document.querySelector('.cli-input-bar')
- );
- }
-
- const body =
- document.getElementById(`terminal-body-${term.id}`) ||
- (term.id === 1
- ? document.getElementById('terminal-body')
- : null);
-
- if (!body) continue;
-
- body.innerHTML = '';
-
- for (const line of term.lines || []) {
- const div = document.createElement('div');
-
- div.className =
- `cli-output-block ${line.className}`;
-
- div.textContent = line.text;
-
- body.appendChild(div);
- }
- }
-
- switchTerminal(state.activeTerminalId);
-
- console.log('Session restored successfully');
-
- } catch (e) {
- console.error('Failed to restore session:', e);
- }
-}
// ─── Terminal Utility Actions ───────────────────────────────
@@ -2478,7 +2486,7 @@ function toggleAutoScroll() {
state.autoScroll[termId] = state.autoScroll[termId] === false ? true : false;
const isOn = state.autoScroll[termId] !== false;
updateAutoScrollBtn(termId, isOn);
- notify(`Auto-scroll ${isOn ? 'enabled' : 'disabled'} for Terminal ${termId}.`, 'info');
+ notify(`Auto-scroll ${isOn ? 'enabled' : 'disabled'} for ${getTerminalDisplayName(termId)}.`, 'info');
}
/**
@@ -2533,7 +2541,7 @@ function setTerminalDensity(density) {
function clearCli() {
const termBody = getTerminalBody(state.activeTerminalId);
if (termBody) {
- termBody.innerHTML = '$ Terminal cleared.
';
+ termBody.innerHTML = '$Terminal cleared.
Ready for new commands.
';
}
document.getElementById('run-status').textContent = '';
document.getElementById('run-status').className = 'run-status';
@@ -2553,11 +2561,8 @@ async function saveSession() {
timestamp: Date.now(),
terminals: state.terminals.map(id => {
- const body =
- document.getElementById(`terminal-body-${id}`) ||
- (id === 1
- ? document.getElementById('terminal-body')
- : null);
+ id = Number(id);
+ const body = getTerminalBody(id);
if (!body) return null;
@@ -2579,8 +2584,8 @@ async function saveSession() {
};
}).filter(t => t !== null),
- activeTerminalId: state.activeTerminalId,
- nextTerminalId: state.nextTerminalId,
+ activeTerminalId: Number(state.activeTerminalId),
+ nextTerminalId: Number(state.nextTerminalId),
cmdHistory: state.cmdHistory,
cmdHistoryIndex: state.cmdHistoryIndex,
@@ -2647,97 +2652,77 @@ async function restoreSession() {
state.sessionId = session.sessionId || null;
- const terminalIds = session.terminals?.map(t => t.id);
+ const terminalIds = session.terminals?.map(t => Number(t.id));
state.terminals = terminalIds?.length ? terminalIds : [1];
- state.activeTerminalId =
- session.activeTerminalId || 1;
-
- state.nextTerminalId =
- Math.max(...state.terminals) + 1;
+ state.activeTerminalId = Number(session.activeTerminalId || 1);
- state.cmdHistory =
- session.cmdHistory || [];
+ state.nextTerminalId = Math.max(...state.terminals, 1) + 1;
- state.cmdHistoryIndex =
- session.cmdHistoryIndex || -1;
+ state.cmdHistory = session.cmdHistory || [];
+ state.cmdHistoryIndex = session.cmdHistoryIndex || -1;
restoreUnlockedScripts(session.unlockedScripts);
- const existingTabs =
- document.querySelectorAll('.cli-tab');
-
- existingTabs.forEach(tab => {
- if (tab.id !== 'tab-btn-1') {
- tab.remove();
- }
+ // Remove all existing terminal tabs from the DOM
+ document.querySelectorAll('.cli-tab').forEach(tab => {
+ tab.remove();
});
- const existingBodies =
- document.querySelectorAll('.cli-body');
-
- existingBodies.forEach(body => {
- if (body.id !== 'terminal-body') {
- body.remove();
- }
+ // Remove all existing terminal bodies from the DOM
+ document.querySelectorAll('.cli-body').forEach(body => {
+ body.remove();
});
- for (const term of session.terminals || []) {
-
- if (term.id !== 1) {
- // Create terminal DOM directly with the saved ID
- // instead of calling addTerminal() which would
- // corrupt state.nextTerminalId and state.terminals
- const tabsContainer = document.getElementById('cli-tabs');
- const tabBtn = document.createElement('div');
- tabBtn.className = 'cli-tab';
- tabBtn.id = `tab-btn-${term.id}`;
- tabBtn.innerHTML = `
-
-
-
-
-
- Terminal ${term.id}
- `;
- tabBtn.onclick = () => switchTerminal(term.id);
- tabsContainer.insertBefore(tabBtn, document.getElementById('btn-add-tab'));
-
- const bodyContainer = document.createElement('div');
- bodyContainer.className = 'cli-body';
- bodyContainer.setAttribute('role', 'log');
- bodyContainer.setAttribute('aria-live', 'polite');
- bodyContainer.id = `terminal-body-${term.id}`;
- bodyContainer.style.display = 'none';
-
- document.getElementById('cli-area').insertBefore(
- bodyContainer,
- document.querySelector('.cli-input-bar')
- );
- }
-
- const body =
- document.getElementById(`terminal-body-${term.id}`) ||
- (term.id === 1
- ? document.getElementById('terminal-body')
- : null);
-
- if (!body) continue;
-
- body.innerHTML = '';
-
- for (const line of term.lines || []) {
- const div = document.createElement('div');
+ const tabsContainer = document.getElementById('cli-tabs');
+ const cliArea = document.getElementById('cli-area');
- div.className =
- `cli-output-block ${line.className}`;
-
- div.textContent = line.text;
+ for (const term of session.terminals || []) {
+ const termId = Number(term.id);
+
+ const tabBtn = document.createElement('div');
+ tabBtn.className = 'cli-tab';
+ tabBtn.id = `tab-btn-${termId}`;
+ tabBtn.dataset.id = termId;
+ tabBtn.innerHTML = `
+ >_
+ ${getTerminalDisplayName(termId)}
+ `;
+ tabBtn.onclick = () => switchTerminal(termId);
+ tabsContainer.insertBefore(tabBtn, document.getElementById('btn-add-tab'));
+
+ const bodyContainer = document.createElement('div');
+ bodyContainer.className = 'cli-body';
+ bodyContainer.setAttribute('role', 'log');
+ bodyContainer.setAttribute('aria-live', 'polite');
+ bodyContainer.id = `terminal-body-${termId}`;
+ bodyContainer.style.display = 'none';
+
+ cliArea.insertBefore(
+ bodyContainer,
+ document.querySelector('.cli-input-bar')
+ );
- body.appendChild(div);
+ // Populate terminal log lines
+ bodyContainer.innerHTML = '';
+ if (!term.lines || term.lines.length === 0) {
+ if (termId === 1) {
+ bodyContainer.innerHTML = '$Welcome to DevShell.
Select a script to run, or type a command below.
';
+ } else {
+ bodyContainer.innerHTML = '$Terminal ready.
Ready for interaction.
';
+ }
+ } else {
+ for (const line of term.lines || []) {
+ const div = document.createElement('div');
+ div.className = `cli-output-block ${line.className}`;
+ div.textContent = line.text;
+ bodyContainer.appendChild(div);
+ }
}
}
+ renumberTabs();
+ state.terminals.forEach(termId => updateTabStatusIndicator(termId));
switchTerminal(state.activeTerminalId);
console.log('Session restored successfully');
@@ -2759,15 +2744,12 @@ function addTerminal() {
tabBtn.className = 'cli-tab';
tabBtn.id = `tab-btn-${id}`;
tabBtn.innerHTML = `
-
-
-
-
-
- Terminal ${id}
+ >_
+ ${getTerminalDisplayName(id)}
`;
tabBtn.onclick = () => switchTerminal(id);
tabsContainer.insertBefore(tabBtn, document.getElementById('btn-add-tab'));
+ renumberTabs();
const bodyContainer = document.createElement('div');
bodyContainer.className = 'cli-body';
@@ -2775,7 +2757,7 @@ function addTerminal() {
bodyContainer.setAttribute('aria-live', 'polite');
bodyContainer.id = `terminal-body-${id}`;
bodyContainer.style.display = 'none';
- bodyContainer.innerHTML = '$ Terminal ready.
';
+ bodyContainer.innerHTML = '$Terminal ready.
Ready for interaction.
';
document.getElementById('cli-area').insertBefore(bodyContainer, document.querySelector('.cli-input-bar'));
applyTerminalDensity();
@@ -2785,6 +2767,7 @@ function addTerminal() {
}
function switchTerminal(id) {
+ id = Number(id);
state.activeTerminalId = id;
document.querySelectorAll('.cli-tab').forEach(t => t.classList.remove('active'));
@@ -2818,6 +2801,7 @@ function switchTerminal(id) {
}
function closeTerminal(id) {
+ id = Number(id);
if (state.terminals.length <= 1) return;
if (state.runningScripts && state.runningScripts[id]) {
@@ -2826,9 +2810,11 @@ function closeTerminal(id) {
state.terminals = state.terminals.filter(t => t !== id);
delete state.autoScroll[id];
+ delete state.terminalStatuses[id];
const tabBtn = document.getElementById(`tab-btn-${id}`) || document.querySelector(`.cli-tab[data-id="${id}"]`);
if (tabBtn) tabBtn.remove();
+ renumberTabs();
const bodyContainer = getTerminalBody(id);
if (bodyContainer) bodyContainer.remove();
@@ -2911,7 +2897,8 @@ function renderSidebar() {
const tree = document.getElementById('category-tree');
const countEl = document.getElementById('script-count');
const favsSection = document.getElementById('favorites-section');
- const favsList = document.getElementById('favorites-list');
+
+ if (favsSection) favsSection.style.display = 'none';
let totalScripts = 0;
let favScripts = [];
@@ -2919,6 +2906,63 @@ function renderSidebar() {
const query = state.searchQuery.toLowerCase();
+ // Collect all favorites matching search query
+ for (const [cat, scripts] of Object.entries(state.scripts)) {
+ scripts.forEach(s => {
+ if (s.favorite) {
+ const matches = !query ||
+ s.name.toLowerCase().includes(query) ||
+ (s.desc && s.desc.toLowerCase().includes(query)) ||
+ (s.tag && s.tag.toLowerCase().includes(query)) ||
+ s.file.toLowerCase().includes(query);
+ if (matches) {
+ favScripts.push(s);
+ }
+ }
+ });
+ }
+
+ // Render Favorites virtual category folder
+ if (favScripts.length > 0) {
+ const isExpanded = state.expandedCategories.has('Favorites') || !!query;
+ html += `
+
+
+
+ ${favScripts.map(s => {
+ let lockIcon = s.locked ? `${ICONS.lock}` : '';
+ const displayName = s.name && s.name.trim() ? s.name : s.file.replace('.sh', '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
+ return `
+ -
+ ${lockIcon}
+ ${ICONS.script}
+ ${escapeHtml(displayName)}
+
+ ${ICONS.favorite}
+
+
+ `}).join('')}
+
+
+ `;
+ }
+
+ // Render other categories
for (const [cat, scripts] of Object.entries(state.scripts)) {
const filteredScripts = query
? scripts.filter(s =>
@@ -2946,8 +2990,7 @@ function renderSidebar() {
${filteredScripts.map(s => {
let lockIcon = s.locked ? `${ICONS.lock}` : '';
- const displayName = ((s.name || '') + '').trim() || s.file || (s.relative_path || '').split('/').pop() || '';
-
+ const displayName = s.name && s.name.trim() ? s.name : s.file.replace('.sh', '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
return `
-
`;
-
- // Populate favs
- scripts.forEach(s => { if (s.favorite) favScripts.push(s); });
}
// Wrap categories under a top-level 'Scripts' root folder for dropdown behavior
@@ -2999,22 +3039,6 @@ function renderSidebar() {
`);
countEl.textContent = totalScripts;
-
- if (favScripts.length > 0) {
- favsSection.style.display = '';
- favsList.innerHTML = safeHTML(favScripts.map(s => {
- const displayName = ((s.name || '') + '').trim() || s.file || (s.relative_path || '').split('/').pop() || '';
- return `
-
-
- ${ICONS.favorite}
- ${escapeHtml(displayName)}
-
- `}).join(''));
- } else {
- favsSection.style.display = 'none';
- }
}
function renderWelcomeStats() {
@@ -3101,8 +3125,9 @@ async function selectScript(relPath) {
detailPanel.style.display = '';
// Fill details
+ const scriptDisplayName = script.name && script.name.trim() ? script.name : script.file.replace('.sh', '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
document.getElementById('detail-category').textContent = script.category;
- document.getElementById('detail-name').textContent = script.name;
+ document.getElementById('detail-name').textContent = scriptDisplayName;
document.getElementById('detail-desc').textContent = script.desc || 'No description provided';
document.getElementById('detail-path').textContent = script.relative_path;
@@ -3356,62 +3381,97 @@ function bindEvents() {
if (scriptSearchBar) {
scriptSearchBar.addEventListener('input', (e) => {
const filterText = e.target.value.toLowerCase().trim();
- const scriptItems = document.querySelectorAll('#category-tree .script-item');
-
- // 1. Match item against five fields
- scriptItems.forEach(item => {
- const scriptNameEl = item.querySelector('.script-item-name');
- if (!scriptNameEl) return;
-
- const name = scriptNameEl.textContent.toLowerCase();
- const filename = (item.getAttribute('data-file') || '').toLowerCase();
- const path = (item.getAttribute('data-path') || '').toLowerCase();
- const tag = (item.getAttribute('data-tag') || '').toLowerCase();
- const desc = (item.getAttribute('data-desc') || '').toLowerCase();
-
- if (
- name.includes(filterText) ||
- filename.includes(filterText) ||
- path.includes(filterText) ||
- tag.includes(filterText) ||
- desc.includes(filterText)
- ) {
- item.style.display = 'flex';
- } else {
- item.style.display = 'none';
- }
- });
-
- // 2. Hide/show category wrappers (.category-section) based on visibility of their script items
const categorySections = document.querySelectorAll('#category-tree .category-section');
+
categorySections.forEach(section => {
- const items = section.querySelectorAll('.script-item');
- let hasVisible = false;
- items.forEach(item => {
- if (item.style.display !== 'none') {
- hasVisible = true;
+ const scriptItems = section.querySelectorAll('.script-item');
+ let visibleCount = 0;
+
+ scriptItems.forEach(item => {
+ if (filterText === '') {
+ item.style.display = 'flex';
+ visibleCount++;
+ return;
+ }
+
+ // Read 5 fields
+ const name = (item.querySelector('.script-item-name')?.textContent || '').toLowerCase();
+ const file = (item.getAttribute('data-file') || '').toLowerCase();
+ const path = (item.getAttribute('data-path') || '').toLowerCase();
+ const tag = (item.getAttribute('data-tag') || '').toLowerCase();
+ const desc = (item.getAttribute('data-desc') || '').toLowerCase();
+
+ if (name.includes(filterText) ||
+ file.includes(filterText) ||
+ path.includes(filterText) ||
+ tag.includes(filterText) ||
+ desc.includes(filterText)) {
+ item.style.display = 'flex';
+ visibleCount++;
+ } else {
+ item.style.display = 'none';
}
});
- if (hasVisible || filterText === '') {
- section.style.display = '';
- } else {
+
+ // Handle category section visibility
+ if (filterText !== '' && visibleCount === 0) {
section.style.display = 'none';
+ } else {
+ section.style.display = 'block';
}
- });
- // Handle category auto-expansion smoothly without resetting terminal CSS
- const categoryLists = document.querySelectorAll('#category-tree .script-list');
- categoryLists.forEach(list => {
- if (filterText !== '') {
- list.style.maxHeight = 'none';
- list.classList.remove('collapsed');
- } else {
- list.style.maxHeight = '';
+ // Handle category list auto-expansion
+ const list = section.querySelector('.script-list');
+ const arrow = section.querySelector('.category-arrow');
+ const header = section.querySelector('.category-header');
+ if (list) {
+ if (filterText !== '' && visibleCount > 0) {
+ list.style.maxHeight = 'none';
+ list.classList.remove('collapsed');
+ if (arrow) arrow.classList.add('expanded');
+ if (header) header.setAttribute('aria-expanded', 'true');
+ } else if (filterText === '') {
+ // Restore state based on state.expandedCategories
+ const catName = section.dataset.category;
+ const isExpanded = state.expandedCategories.has(catName);
+ if (isExpanded) {
+ list.style.maxHeight = `${scriptItems.length * 44}px`;
+ list.classList.remove('collapsed');
+ if (arrow) arrow.classList.add('expanded');
+ if (header) header.setAttribute('aria-expanded', 'true');
+ } else {
+ list.style.maxHeight = '0px';
+ list.classList.add('collapsed');
+ if (arrow) arrow.classList.remove('expanded');
+ if (header) header.setAttribute('aria-expanded', 'false');
+ }
+ }
}
});
});
}
+ // Collapsible Sidebar Toggle
+ const btnToggleSidebar = document.getElementById('btn-toggle-sidebar');
+ if (btnToggleSidebar) {
+ btnToggleSidebar.addEventListener('click', () => {
+ const sidebar = document.getElementById('sidebar');
+ const resizerLeft = document.getElementById('resizer-left');
+ if (sidebar) sidebar.classList.toggle('collapsed');
+ if (resizerLeft) resizerLeft.classList.toggle('collapsed');
+ });
+ }
+
+ // Font Size Controls
+ const btnFontDecrease = document.getElementById('btn-font-decrease');
+ const btnFontIncrease = document.getElementById('btn-font-increase');
+ if (btnFontDecrease) {
+ btnFontDecrease.addEventListener('click', () => changeTerminalFontSize(-1));
+ }
+ if (btnFontIncrease) {
+ btnFontIncrease.addEventListener('click', () => changeTerminalFontSize(1));
+ }
+
// ─── THEME TOGGLE ENGINE LAYER ───
const themeToggleBtn = document.getElementById('theme-toggle-btn');
const moonIcon = document.getElementById('theme-icon-moon');
@@ -3657,7 +3717,7 @@ function bindEvents() {
const historyClearBtn = document.getElementById('history-clear-btn');
if (historyClearBtn) {
historyClearBtn.addEventListener('click', async () => {
- const confirmation = confirm('Are you sure you want to permanently clear your command and execution history? This will delete all script and command run logs, and reset CLI command history. This action cannot be undone.');
+ const confirmation = await showCustomConfirm('Are you sure you want to permanently clear your command history log?', 'Clear History');
if (!confirmation) return;
try {
@@ -4158,18 +4218,18 @@ function notify(message, type = 'info') {
function serializeWorkspace() {
const terminalSnapshots = state.terminals.map(id => {
- const terminalBody = document.getElementById(`terminal-body-${id}`);
+ const terminalBody = getTerminalBody(id);
return {
- id,
+ id: Number(id),
content: terminalBody?.innerHTML || '',
pendingInput: document.getElementById('cli-input')?.value || ''
};
});
return {
- terminals: state.terminals,
+ terminals: state.terminals.map(Number),
terminalSnapshots,
- activeTerminalId: state.activeTerminalId,
+ activeTerminalId: Number(state.activeTerminalId),
activeScript: state.activeScript,
searchQuery: state.searchQuery,
debuggerVisible:
@@ -4306,6 +4366,9 @@ function sanitizeWorkspaceSnapshot(data) {
}
function rebuildTerminalWorkspace(terminals, activeTerminalId, dataSnapshots = []) {
+ terminals = terminals.map(Number);
+ activeTerminalId = Number(activeTerminalId);
+
const tabsContainer = document.getElementById('cli-tabs');
const cliArea = document.getElementById('cli-area');
@@ -4333,19 +4396,10 @@ function rebuildTerminalWorkspace(terminals, activeTerminalId, dataSnapshots = [
tabBtn.dataset.id = id;
tabBtn.id = `tab-btn-${id}`;
tabBtn.innerHTML = `
-
-
-
-
- Terminal ${id}
-
-
- `;
+ >_
+ ${getTerminalDisplayName(id)}
+ `;
tabBtn.onclick = () => switchTerminal(id);
- tabBtn.querySelector('.cli-tab-close')?.addEventListener('click', (e) => {
- e.stopPropagation();
- closeTerminal(id);
- });
tabsContainer.insertBefore(tabBtn, document.getElementById('btn-add-tab'));
const bodyContainer = document.createElement('div');
@@ -4354,6 +4408,8 @@ function rebuildTerminalWorkspace(terminals, activeTerminalId, dataSnapshots = [
bodyContainer.style.display = 'none';
bodyContainer.setAttribute('role', 'log');
bodyContainer.setAttribute('aria-live', 'polite');
+ const snapshot = dataSnapshots?.find(snap => Number(snap.id) === id);
+ bodyContainer.innerHTML = snapshot?.content || '$Restored terminal session.
Ready for interaction.
';
const snapshot = dataSnapshots?.find(snap => snap.id === id);
bodyContainer.innerHTML = safeHTML(snapshot?.content ||
`
@@ -4365,6 +4421,11 @@ function rebuildTerminalWorkspace(terminals, activeTerminalId, dataSnapshots = [
state.terminals.push(id);
});
+ renumberTabs();
+ terminals.forEach(id => {
+ updateTabStatusIndicator(id);
+ });
+
// Restore pending input from first snapshot
const firstSnapshot = dataSnapshots?.[0];
if (firstSnapshot?.pendingInput) {
@@ -4575,7 +4636,7 @@ async function loadWorkspaceProfile(name) {
}
async function deleteWorkspaceProfile(name) {
- const confirmed = confirm(`Delete workspace profile "${name}"?`);
+ const confirmed = await showCustomConfirm(`Delete workspace profile "${name}"?`, 'Delete Profile');
if (!confirmed) {
return;
}
diff --git a/ui/index.html b/ui/index.html
index 58399eb..8211ba7 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -30,6 +30,11 @@
DevShell
v1.0
+
@@ -283,13 +288,9 @@ Scripts