feat: more phase 1 implementation - proper parity now

This commit is contained in:
2026-02-22 22:39:26 +01:00
parent 3ec8819d6d
commit 7cb47e0aa5
13 changed files with 694 additions and 56 deletions

View File

@@ -94,7 +94,11 @@ export class ScriptEngine extends EventEmitter {
} }
const allScripts = await this.getAllScriptRows(); const allScripts = await this.getAllScriptRows();
const desiredSlug = this.normalizeSlug(updates.slug || updates.title || existing.slug); const desiredSlug = typeof updates.slug === 'string'
? this.normalizeSlug(updates.slug)
: typeof updates.title === 'string'
? this.normalizeSlug(updates.title)
: existing.slug;
const nextSlug = this.ensureUniqueSlug(desiredSlug, allScripts, existing.id); const nextSlug = this.ensureUniqueSlug(desiredSlug, allScripts, existing.id);
const nextFilePath = this.getScriptFilePath(nextSlug); const nextFilePath = this.getScriptFilePath(nextSlug);
const now = new Date(); const now = new Date();
@@ -228,8 +232,8 @@ export class ScriptEngine extends EventEmitter {
private normalizeSlug(value: string): string { private normalizeSlug(value: string): string {
const normalized = value const normalized = value
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '_')
.replace(/^-|-$/g, ''); .replace(/^_+|_+$/g, '');
return normalized || 'script'; return normalized || 'script';
} }
@@ -246,11 +250,11 @@ export class ScriptEngine extends EventEmitter {
} }
let suffix = 2; let suffix = 2;
while (taken.has(`${baseSlug}-${suffix}`)) { while (taken.has(`${baseSlug}_${suffix}`)) {
suffix += 1; suffix += 1;
} }
return `${baseSlug}-${suffix}`; return `${baseSlug}_${suffix}`;
} }
} }

View File

@@ -1,32 +1,54 @@
.scripts-view { .scripts-view-shell {
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column;
min-height: 0; min-height: 0;
width: 100%;
} }
.scripts-editor { .scripts-view {
display: flex;
flex-direction: column;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
width: 100%; }
padding: 10px;
.scripts-meta-row {
margin-bottom: 8px;
}
.scripts-enabled-field {
align-self: flex-end;
}
.scripts-enabled-field label {
display: flex;
align-items: center;
gap: 8px; gap: 8px;
} }
.scripts-toolbar { .scripts-editor {
display: flex;
justify-content: flex-end;
}
.scripts-label {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.scripts-textarea {
width: 100%;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.scripts-toolbar {
margin-bottom: 8px;
}
.scripts-monaco {
flex: 1;
min-height: 0;
border-radius: 4px;
overflow: hidden;
background-color: var(--vscode-input-background);
}
.scripts-run-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
}
.scripts-save-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
}

View File

@@ -1,20 +1,46 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import MonacoEditor from '@monaco-editor/react';
import type { ScriptData } from '../../../main/shared/electronApi'; import type { ScriptData } from '../../../main/shared/electronApi';
import { useAppStore } from '../../store'; import { useAppStore } from '../../store';
import { getPythonRuntimeManager } from '../../python/runtimeManagerInstance'; import { getPythonRuntimeManager } from '../../python/runtimeManagerInstance';
import { useI18n } from '../../i18n'; import { useI18n } from '../../i18n';
import { showToast } from '../Toast';
import './ScriptsView.css'; import './ScriptsView.css';
const UI_DATE_LOCALE: Record<string, string> = {
en: 'en-US',
de: 'de-DE',
fr: 'fr-FR',
it: 'it-IT',
es: 'es-ES',
};
interface ScriptsViewProps { interface ScriptsViewProps {
scriptId: string | null; scriptId: string | null;
} }
export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => { export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
const { t } = useI18n(); const { t, language } = useI18n();
const appendPanelOutputEntry = useAppStore((state) => state.appendPanelOutputEntry); const appendPanelOutputEntry = useAppStore((state) => state.appendPanelOutputEntry);
const closeTab = useAppStore((state) => state.closeTab);
const [script, setScript] = useState<ScriptData | null>(null); const [script, setScript] = useState<ScriptData | null>(null);
const [title, setTitle] = useState('');
const [slug, setSlug] = useState('');
const [kind, setKind] = useState<ScriptData['kind']>('utility');
const [entrypoint, setEntrypoint] = useState('render');
const [enabled, setEnabled] = useState(true);
const [scriptContent, setScriptContent] = useState(''); const [scriptContent, setScriptContent] = useState('');
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const toFunctionSlug = (value: string) => {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return normalized || 'script';
};
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -22,19 +48,38 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
const loadScript = async () => { const loadScript = async () => {
if (!scriptId) { if (!scriptId) {
setScript(null); setScript(null);
setTitle('');
setSlug('');
setKind('utility');
setEntrypoint('render');
setEnabled(true);
setScriptContent(''); setScriptContent('');
setIsSlugManuallyEdited(false);
return; return;
} }
const item = await window.electronAPI?.scripts.get(scriptId); const item = await window.electronAPI?.scripts.get(scriptId);
if (cancelled || !item) { if (cancelled || !item) {
setScript(null); setScript(null);
setTitle('');
setSlug('');
setKind('utility');
setEntrypoint('render');
setEnabled(true);
setScriptContent(''); setScriptContent('');
setIsSlugManuallyEdited(false);
return; return;
} }
setScript(item); setScript(item);
setTitle(item.title || '');
setSlug(toFunctionSlug(item.slug || item.title || ''));
setKind(item.kind || 'utility');
setEntrypoint(item.entrypoint || 'render');
setEnabled(item.enabled ?? true);
setScriptContent(item.content || ''); setScriptContent(item.content || '');
const normalizedExisting = toFunctionSlug(item.slug || item.title || '');
setIsSlugManuallyEdited(normalizedExisting !== toFunctionSlug(item.title || ''));
}; };
void loadScript(); void loadScript();
@@ -44,6 +89,101 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
}; };
}, [scriptId]); }, [scriptId]);
const hasChanges = !!script && (
title !== script.title ||
slug !== script.slug ||
kind !== script.kind ||
entrypoint !== script.entrypoint ||
enabled !== script.enabled ||
scriptContent !== script.content
);
const handleTitleChange = (nextTitle: string) => {
setTitle(nextTitle);
if (!isSlugManuallyEdited) {
setSlug(toFunctionSlug(nextTitle));
}
};
const handleSlugChange = (nextSlug: string) => {
setIsSlugManuallyEdited(true);
setSlug(toFunctionSlug(nextSlug));
};
const handleSaveScript = async () => {
if (!script || isSaving || !hasChanges) {
return;
}
setIsSaving(true);
try {
const updated = await window.electronAPI?.scripts.update(script.id, {
title,
slug,
kind,
entrypoint,
enabled,
content: scriptContent,
});
if (!updated) {
return;
}
setScript(updated);
setTitle(updated.title || '');
setSlug(toFunctionSlug(updated.slug || updated.title || ''));
setKind(updated.kind || 'utility');
setEntrypoint(updated.entrypoint || 'render');
setEnabled(updated.enabled ?? true);
setScriptContent(updated.content || '');
const normalizedExisting = toFunctionSlug(updated.slug || updated.title || '');
setIsSlugManuallyEdited(normalizedExisting !== toFunctionSlug(updated.title || ''));
if (typeof window.dispatchEvent === 'function') {
window.dispatchEvent(new CustomEvent('bds:scripts-changed'));
}
} finally {
setIsSaving(false);
}
};
const handleDeleteScript = async () => {
if (!script) {
return;
}
try {
const deleted = await window.electronAPI?.scripts.delete(script.id);
if (!deleted) {
showToast.error(t('sidebar.scripts.deleteFailed'));
return;
}
closeTab(script.id);
if (typeof window.dispatchEvent === 'function') {
window.dispatchEvent(new CustomEvent('bds:scripts-changed'));
}
} catch (error) {
console.error('Failed to delete script:', error);
showToast.error(t('sidebar.scripts.deleteFailed'));
}
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault();
void handleSaveScript();
}
};
if (typeof window.addEventListener !== 'function' || typeof window.removeEventListener !== 'function') {
return;
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSaveScript]);
const handleRunScript = async () => { const handleRunScript = async () => {
if (!script || isRunning) { if (!script || isRunning) {
return; return;
@@ -90,25 +230,154 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
}; };
return ( return (
<div className="scripts-view"> <div className="scripts-view-shell">
<div className="scripts-editor"> <div className="editor-header scripts-header">
<div className="scripts-toolbar"> <div className="editor-tabs">
<button type="button" onClick={handleRunScript} disabled={!script || isRunning}> <div className="editor-tab active">
<span className="editor-tab-title">{title || t('editor.untitled')}</span>
</div>
</div>
<div className="editor-actions">
<button
type="button"
className="scripts-save-button"
onClick={handleSaveScript}
disabled={!script || !hasChanges || isSaving}
>
{isSaving ? t('editor.saving') : t('scripts.save')}
</button>
<button
type="button"
className="scripts-run-button"
onClick={handleRunScript}
disabled={!script || isRunning}
>
{t('scripts.run')} {t('scripts.run')}
</button> </button>
<button
type="button"
className="secondary danger"
onClick={handleDeleteScript}
disabled={!script}
title={t('scripts.delete')}
>
{t('scripts.delete')}
</button>
</div>
</div> </div>
<label className="scripts-label" htmlFor="scripts-content"> <div className="editor-content scripts-view">
{t('scripts.content')} <div className="editor-header-row scripts-meta-row">
</label> <div className="editor-meta">
<textarea <div className="editor-field-row">
id="scripts-content" <div className="editor-field">
className="scripts-textarea" <label htmlFor="script-title">{t('editor.field.title')}</label>
value={scriptContent} <input
onChange={(event) => setScriptContent(event.target.value)} id="script-title"
type="text"
value={title}
onChange={(event) => handleTitleChange(event.target.value)}
disabled={!script}
/>
</div>
<div className="editor-field">
<label htmlFor="script-slug">{t('editor.field.slug')}</label>
<input
id="script-slug"
type="text"
value={slug}
onChange={(event) => handleSlugChange(event.target.value)}
disabled={!script} disabled={!script}
/> />
</div> </div>
</div> </div>
<div className="editor-field-row">
<div className="editor-field">
<label htmlFor="script-kind">{t('scripts.field.kind')}</label>
<select
id="script-kind"
value={kind}
onChange={(event) => setKind(event.target.value as ScriptData['kind'])}
disabled={!script}
>
<option value="utility">{t('scripts.kind.utility')}</option>
<option value="macro">{t('scripts.kind.macro')}</option>
<option value="transform">{t('scripts.kind.transform')}</option>
</select>
</div>
<div className="editor-field">
<label htmlFor="script-entrypoint">{t('scripts.field.entrypoint')}</label>
<input
id="script-entrypoint"
type="text"
value={entrypoint}
onChange={(event) => setEntrypoint(event.target.value)}
disabled={!script}
/>
</div>
<div className="editor-field scripts-enabled-field">
<label htmlFor="script-enabled">
<input
id="script-enabled"
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
disabled={!script}
/>
{t('scripts.field.enabled')}
</label>
</div>
</div>
</div>
</div>
<div className="editor-body scripts-editor">
<div className="editor-toolbar scripts-toolbar">
<div className="editor-toolbar-left">
<label>{t('scripts.content')}</label>
</div>
<div className="editor-toolbar-center" />
<div className="editor-toolbar-right" />
</div>
<div className="scripts-monaco">
<MonacoEditor
height="100%"
language="python"
theme="vs-dark"
value={scriptContent}
onChange={(value) => setScriptContent(value || '')}
options={{
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
fontSize: 14,
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
padding: { top: 12, bottom: 12 },
automaticLayout: true,
scrollBeyondLastLine: false,
renderLineHighlight: 'line',
formatOnPaste: true,
cursorStyle: 'line',
cursorBlinking: 'smooth',
readOnly: !script,
}}
/>
</div>
</div>
{script && (
<div className="editor-footer">
<span className="text-muted text-small">
{t('editor.footer.created')}: {new Date(script.createdAt).toLocaleString(UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en)}
</span>
<span className="text-muted text-small">
{t('editor.footer.updated')}: {new Date(script.updatedAt).toLocaleString(UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en)}
</span>
</div>
)}
</div>
</div>
); );
}; };

View File

@@ -1667,9 +1667,9 @@ const ImportList: React.FC = () => {
}; };
const ScriptsList: React.FC = () => { const ScriptsList: React.FC = () => {
const { t } = useI18n(); const { t, language } = useI18n();
const { openTab, activeTabId } = useAppStore(); const { openTab, activeTabId, closeTab } = useAppStore();
const [scripts, setScripts] = useState<Array<{ id: string; title: string }>>([]); const [scripts, setScripts] = useState<Array<{ id: string; title: string; updatedAt: string }>>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const loadScripts = useCallback(async () => { const loadScripts = useCallback(async () => {
@@ -1678,7 +1678,7 @@ const ScriptsList: React.FC = () => {
return; return;
} }
setScripts(items.map((item) => ({ id: item.id, title: item.title }))); setScripts(items.map((item) => ({ id: item.id, title: item.title, updatedAt: item.updatedAt })));
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -1692,7 +1692,7 @@ const ScriptsList: React.FC = () => {
return; return;
} }
setScripts((items ?? []).map((item) => ({ id: item.id, title: item.title }))); setScripts((items ?? []).map((item) => ({ id: item.id, title: item.title, updatedAt: item.updatedAt })));
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setIsLoading(false); setIsLoading(false);
@@ -1702,10 +1702,22 @@ const ScriptsList: React.FC = () => {
void loadInitialScripts(); void loadInitialScripts();
const canListen = typeof window.addEventListener === 'function' && typeof window.removeEventListener === 'function';
const handleScriptsChanged = () => {
void loadScripts();
};
if (canListen) {
window.addEventListener('bds:scripts-changed', handleScriptsChanged);
}
return () => { return () => {
cancelled = true; cancelled = true;
if (canListen) {
window.removeEventListener('bds:scripts-changed', handleScriptsChanged);
}
}; };
}, []); }, [loadScripts]);
const handleCreateScript = async () => { const handleCreateScript = async () => {
try { try {
@@ -1722,9 +1734,12 @@ const ScriptsList: React.FC = () => {
} }
setScripts((prev) => [ setScripts((prev) => [
{ id: created.id, title: created.title }, { id: created.id, title: created.title, updatedAt: created.updatedAt },
...prev.filter((script) => script.id !== created.id), ...prev.filter((script) => script.id !== created.id),
]); ]);
if (typeof window.dispatchEvent === 'function') {
window.dispatchEvent(new CustomEvent('bds:scripts-changed'));
}
openScriptTab(openTab, created.id, 'pin'); openScriptTab(openTab, created.id, 'pin');
void loadScripts(); void loadScripts();
} catch (error) { } catch (error) {
@@ -1733,6 +1748,40 @@ const ScriptsList: React.FC = () => {
} }
}; };
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
if (diffDays === 0) {
return date.toLocaleTimeString(uiDateLocale, { hour: 'numeric', minute: '2-digit' });
} else if (diffDays === 1) {
return t('sidebar.chat.yesterday');
} else if (diffDays < 7) {
return date.toLocaleDateString(uiDateLocale, { weekday: 'short' });
}
return date.toLocaleDateString(uiDateLocale, { month: 'short', day: 'numeric' });
};
const handleDeleteScript = async (event: React.MouseEvent, scriptId: string) => {
event.stopPropagation();
try {
const deleted = await window.electronAPI?.scripts.delete(scriptId);
if (!deleted) {
showToast.error(t('sidebar.scripts.deleteFailed'));
return;
}
setScripts((prev) => prev.filter((script) => script.id !== scriptId));
closeTab(scriptId);
if (typeof window.dispatchEvent === 'function') {
window.dispatchEvent(new CustomEvent('bds:scripts-changed'));
}
} catch (error) {
console.error('Failed to delete script:', error);
showToast.error(t('sidebar.scripts.deleteFailed'));
}
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="chat-list"> <div className="chat-list">
@@ -1767,17 +1816,37 @@ const ScriptsList: React.FC = () => {
</div> </div>
) : ( ) : (
scripts.map((script) => ( scripts.map((script) => (
<button <div
key={script.id} key={script.id}
type="button" role="button"
tabIndex={0}
aria-label={script.title}
className={`chat-list-item ${activeTabId === script.id ? 'active' : ''}`} className={`chat-list-item ${activeTabId === script.id ? 'active' : ''}`}
onClick={() => openScriptTab(openTab, script.id, 'preview')} onClick={() => openScriptTab(openTab, script.id, 'preview')}
onDoubleClick={() => openScriptTab(openTab, script.id, 'pin')} onDoubleClick={() => openScriptTab(openTab, script.id, 'pin')}
onKeyDown={(event) => {
if (event.key === 'Enter') {
openScriptTab(openTab, script.id, 'pin');
return;
}
if (event.key === ' ') {
event.preventDefault();
openScriptTab(openTab, script.id, 'preview');
}
}}
> >
<div className="chat-item-content"> <div className="chat-item-content">
<div className="chat-item-title">{script.title}</div> <div className="chat-item-title">{script.title}</div>
<div className="chat-item-date">{formatDate(script.updatedAt)}</div>
</div> </div>
<button
className="chat-item-delete"
onClick={(event) => handleDeleteScript(event, script.id)}
title={t('sidebar.scripts.deleteScript')}
>
×
</button> </button>
</div>
)) ))
)} )}
</div> </div>

View File

@@ -419,7 +419,15 @@
"sidebar.nav.style": "Stil", "sidebar.nav.style": "Stil",
"sidebar.nav.scripts": "Skripte", "sidebar.nav.scripts": "Skripte",
"scripts.run": "Skript ausführen", "scripts.run": "Skript ausführen",
"scripts.save": "Skript speichern",
"scripts.delete": "Skript löschen",
"scripts.content": "Skriptinhalt", "scripts.content": "Skriptinhalt",
"scripts.field.kind": "Typ",
"scripts.field.entrypoint": "Einstiegspunkt",
"scripts.field.enabled": "Aktiviert",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",
"scripts.kind.transform": "transform",
"sidebar.tagCloud": "Tag-Wolke", "sidebar.tagCloud": "Tag-Wolke",
"sidebar.createEdit": "Erstellen & Bearbeiten", "sidebar.createEdit": "Erstellen & Bearbeiten",
"sidebar.mergeTags": "Tags zusammenführen", "sidebar.mergeTags": "Tags zusammenführen",
@@ -705,6 +713,8 @@
"sidebar.scripts.none": "Noch keine Skripte", "sidebar.scripts.none": "Noch keine Skripte",
"sidebar.scripts.createScript": "Ein Skript erstellen", "sidebar.scripts.createScript": "Ein Skript erstellen",
"sidebar.scripts.createFailed": "Skript konnte nicht erstellt werden", "sidebar.scripts.createFailed": "Skript konnte nicht erstellt werden",
"sidebar.scripts.deleteScript": "Skript löschen",
"sidebar.scripts.deleteFailed": "Skript konnte nicht gelöscht werden",
"sidebar.import.none": "Noch keine Importdefinitionen", "sidebar.import.none": "Noch keine Importdefinitionen",
"sidebar.import.createDefinition": "Eine Importdefinition erstellen", "sidebar.import.createDefinition": "Eine Importdefinition erstellen",
"sidebar.import.deleteDefinition": "Importdefinition löschen", "sidebar.import.deleteDefinition": "Importdefinition löschen",

View File

@@ -419,7 +419,15 @@
"sidebar.nav.style": "Style", "sidebar.nav.style": "Style",
"sidebar.nav.scripts": "Scripts", "sidebar.nav.scripts": "Scripts",
"scripts.run": "Run Script", "scripts.run": "Run Script",
"scripts.save": "Save Script",
"scripts.delete": "Delete Script",
"scripts.content": "Script Content", "scripts.content": "Script Content",
"scripts.field.kind": "Kind",
"scripts.field.entrypoint": "Entrypoint",
"scripts.field.enabled": "Enabled",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",
"scripts.kind.transform": "transform",
"sidebar.tagCloud": "Tag Cloud", "sidebar.tagCloud": "Tag Cloud",
"sidebar.createEdit": "Create & Edit", "sidebar.createEdit": "Create & Edit",
"sidebar.mergeTags": "Merge Tags", "sidebar.mergeTags": "Merge Tags",
@@ -705,6 +713,8 @@
"sidebar.scripts.none": "No scripts yet", "sidebar.scripts.none": "No scripts yet",
"sidebar.scripts.createScript": "Create a script", "sidebar.scripts.createScript": "Create a script",
"sidebar.scripts.createFailed": "Failed to create script", "sidebar.scripts.createFailed": "Failed to create script",
"sidebar.scripts.deleteScript": "Delete script",
"sidebar.scripts.deleteFailed": "Failed to delete script",
"sidebar.import.none": "No import definitions yet", "sidebar.import.none": "No import definitions yet",
"sidebar.import.createDefinition": "Create an import definition", "sidebar.import.createDefinition": "Create an import definition",
"sidebar.import.deleteDefinition": "Delete import definition", "sidebar.import.deleteDefinition": "Delete import definition",

View File

@@ -419,7 +419,15 @@
"sidebar.nav.style": "Estilo", "sidebar.nav.style": "Estilo",
"sidebar.nav.scripts": "Scripts", "sidebar.nav.scripts": "Scripts",
"scripts.run": "Ejecutar script", "scripts.run": "Ejecutar script",
"scripts.save": "Guardar script",
"scripts.delete": "Eliminar script",
"scripts.content": "Contenido del script", "scripts.content": "Contenido del script",
"scripts.field.kind": "Tipo",
"scripts.field.entrypoint": "Punto de entrada",
"scripts.field.enabled": "Habilitado",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",
"scripts.kind.transform": "transform",
"sidebar.tagCloud": "Nube de etiquetas", "sidebar.tagCloud": "Nube de etiquetas",
"sidebar.createEdit": "Crear y editar", "sidebar.createEdit": "Crear y editar",
"sidebar.mergeTags": "Combinar etiquetas", "sidebar.mergeTags": "Combinar etiquetas",
@@ -705,6 +713,8 @@
"sidebar.scripts.none": "Aún no hay scripts", "sidebar.scripts.none": "Aún no hay scripts",
"sidebar.scripts.createScript": "Crear un script", "sidebar.scripts.createScript": "Crear un script",
"sidebar.scripts.createFailed": "No se pudo crear el script", "sidebar.scripts.createFailed": "No se pudo crear el script",
"sidebar.scripts.deleteScript": "Eliminar script",
"sidebar.scripts.deleteFailed": "No se pudo eliminar el script",
"sidebar.import.none": "Sin definiciones de importación", "sidebar.import.none": "Sin definiciones de importación",
"sidebar.import.createDefinition": "Crear definición", "sidebar.import.createDefinition": "Crear definición",
"sidebar.import.deleteDefinition": "Eliminar definición", "sidebar.import.deleteDefinition": "Eliminar definición",

View File

@@ -419,7 +419,15 @@
"sidebar.nav.style": "Style", "sidebar.nav.style": "Style",
"sidebar.nav.scripts": "Scripts", "sidebar.nav.scripts": "Scripts",
"scripts.run": "Exécuter le script", "scripts.run": "Exécuter le script",
"scripts.save": "Enregistrer le script",
"scripts.delete": "Supprimer le script",
"scripts.content": "Contenu du script", "scripts.content": "Contenu du script",
"scripts.field.kind": "Type",
"scripts.field.entrypoint": "Point dentrée",
"scripts.field.enabled": "Activé",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",
"scripts.kind.transform": "transform",
"sidebar.tagCloud": "Nuage détiquettes", "sidebar.tagCloud": "Nuage détiquettes",
"sidebar.createEdit": "Créer & modifier", "sidebar.createEdit": "Créer & modifier",
"sidebar.mergeTags": "Fusionner les étiquettes", "sidebar.mergeTags": "Fusionner les étiquettes",
@@ -705,6 +713,8 @@
"sidebar.scripts.none": "Aucun script", "sidebar.scripts.none": "Aucun script",
"sidebar.scripts.createScript": "Créer un script", "sidebar.scripts.createScript": "Créer un script",
"sidebar.scripts.createFailed": "Impossible de créer le script", "sidebar.scripts.createFailed": "Impossible de créer le script",
"sidebar.scripts.deleteScript": "Supprimer le script",
"sidebar.scripts.deleteFailed": "Impossible de supprimer le script",
"sidebar.import.none": "Aucune définition dimport", "sidebar.import.none": "Aucune définition dimport",
"sidebar.import.createDefinition": "Créer une définition", "sidebar.import.createDefinition": "Créer une définition",
"sidebar.import.deleteDefinition": "Supprimer la définition", "sidebar.import.deleteDefinition": "Supprimer la définition",

View File

@@ -419,7 +419,15 @@
"sidebar.nav.style": "Stile", "sidebar.nav.style": "Stile",
"sidebar.nav.scripts": "Script", "sidebar.nav.scripts": "Script",
"scripts.run": "Esegui script", "scripts.run": "Esegui script",
"scripts.save": "Salva script",
"scripts.delete": "Elimina script",
"scripts.content": "Contenuto script", "scripts.content": "Contenuto script",
"scripts.field.kind": "Tipo",
"scripts.field.entrypoint": "Punto di ingresso",
"scripts.field.enabled": "Abilitato",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",
"scripts.kind.transform": "transform",
"sidebar.tagCloud": "Nuvola tag", "sidebar.tagCloud": "Nuvola tag",
"sidebar.createEdit": "Crea e modifica", "sidebar.createEdit": "Crea e modifica",
"sidebar.mergeTags": "Unisci tag", "sidebar.mergeTags": "Unisci tag",
@@ -705,6 +713,8 @@
"sidebar.scripts.none": "Nessuno script", "sidebar.scripts.none": "Nessuno script",
"sidebar.scripts.createScript": "Crea uno script", "sidebar.scripts.createScript": "Crea uno script",
"sidebar.scripts.createFailed": "Impossibile creare lo script", "sidebar.scripts.createFailed": "Impossibile creare lo script",
"sidebar.scripts.deleteScript": "Elimina script",
"sidebar.scripts.deleteFailed": "Impossibile eliminare lo script",
"sidebar.import.none": "Nessuna definizione di importazione", "sidebar.import.none": "Nessuna definizione di importazione",
"sidebar.import.createDefinition": "Crea definizione", "sidebar.import.createDefinition": "Crea definizione",
"sidebar.import.deleteDefinition": "Elimina definizione", "sidebar.import.deleteDefinition": "Elimina definizione",

View File

@@ -100,9 +100,9 @@ describe('ScriptEngine', () => {
content: 'def render(context):\n return {"html": "<h1>Hi</h1>"}', content: 'def render(context):\n return {"html": "<h1>Hi</h1>"}',
}); });
expect(created.slug).toBe('render-hero'); expect(created.slug).toBe('render_hero');
expect(mockScripts.has(created.id)).toBe(true); expect(mockScripts.has(created.id)).toBe(true);
expect(mockFiles.get('/mock/userData/projects/default/scripts/render-hero.py')).toContain('def render'); expect(mockFiles.get('/mock/userData/projects/default/scripts/render_hero.py')).toContain('def render');
}); });
it('updates script metadata and file content', async () => { it('updates script metadata and file content', async () => {
@@ -117,8 +117,29 @@ describe('ScriptEngine', () => {
content: 'def render(context):\n return {"html": "<h1>Banner</h1>"}', content: 'def render(context):\n return {"html": "<h1>Banner</h1>"}',
}); });
expect(updated?.slug).toBe('render-hero-banner'); expect(updated?.slug).toBe('render_hero_banner');
expect(mockFiles.get('/mock/userData/projects/default/scripts/render-hero-banner.py')).toContain('Banner'); expect(mockFiles.get('/mock/userData/projects/default/scripts/render_hero_banner.py')).toContain('Banner');
});
it('appends underscore numeric suffix for duplicate slugs', async () => {
const first = await scriptEngine.createScript({
title: 'Render Hero',
kind: 'macro',
content: 'def render(context):\n return {"html": "<h1>Hi</h1>"}',
});
vi.mocked((await import('uuid')).v4)
.mockReturnValueOnce('mock-script-id-2');
const second = await scriptEngine.createScript({
title: 'Render Hero',
kind: 'macro',
content: 'def render(context):\n return {"html": "<h1>Again</h1>"}',
});
expect(first.slug).toBe('render_hero');
expect(second.slug).toBe('render_hero_2');
expect(mockFiles.get('/mock/userData/projects/default/scripts/render_hero_2.py')).toContain('Again');
}); });
it('deletes script metadata and source file', async () => { it('deletes script metadata and source file', async () => {
@@ -132,6 +153,6 @@ describe('ScriptEngine', () => {
expect(deleted).toBe(true); expect(deleted).toBe(true);
expect(mockScripts.has(created.id)).toBe(false); expect(mockScripts.has(created.id)).toBe(false);
expect(mockFiles.has('/mock/userData/projects/default/scripts/delete-me.py')).toBe(false); expect(mockFiles.has('/mock/userData/projects/default/scripts/delete_me.py')).toBe(false);
}); });
}); });

View File

@@ -18,6 +18,6 @@ describe('ScriptsView styles', () => {
const css = fs.readFileSync(cssPath, 'utf8'); const css = fs.readFileSync(cssPath, 'utf8');
expect(css).toMatch(/\.scripts-editor\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s); expect(css).toMatch(/\.scripts-editor\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
expect(css).toMatch(/\.scripts-textarea\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s); expect(css).toMatch(/\.scripts-monaco\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
}); });
}); });

View File

@@ -5,6 +5,24 @@ import { ScriptsView } from '../../../src/renderer/components/ScriptsView/Script
import { useAppStore } from '../../../src/renderer/store'; import { useAppStore } from '../../../src/renderer/store';
const executeMock = vi.fn(); const executeMock = vi.fn();
const monacoPropsSpy = vi.fn();
vi.mock('@monaco-editor/react', () => ({
default: (props: {
value?: string;
onChange?: (value?: string) => void;
language?: string;
}) => {
monacoPropsSpy(props);
return (
<textarea
aria-label="Script Content"
value={props.value || ''}
onChange={(event) => props.onChange?.(event.target.value)}
/>
);
},
}));
vi.mock('../../../src/renderer/python/runtimeManagerInstance', () => ({ vi.mock('../../../src/renderer/python/runtimeManagerInstance', () => ({
getPythonRuntimeManager: () => ({ getPythonRuntimeManager: () => ({
@@ -59,6 +77,89 @@ describe('ScriptsView', () => {
fireEvent.change(textarea, { target: { value: 'print("updated")' } }); fireEvent.change(textarea, { target: { value: 'print("updated")' } });
expect(textarea.value).toContain('updated'); expect(textarea.value).toContain('updated');
expect(monacoPropsSpy).toHaveBeenCalledWith(
expect.objectContaining({
language: 'python',
}),
);
});
it('shows metadata fields and footer timestamps', async () => {
render(<ScriptsView scriptId="script-1" />);
const titleInput = await screen.findByLabelText('Title') as HTMLInputElement;
const slugInput = screen.getByLabelText('Slug') as HTMLInputElement;
const kindSelect = screen.getByLabelText('Kind') as HTMLSelectElement;
const entrypointInput = screen.getByLabelText('Entrypoint') as HTMLInputElement;
const enabledInput = screen.getByLabelText('Enabled') as HTMLInputElement;
await vi.waitFor(() => {
expect(titleInput.value).toBe('Hello Script');
expect(slugInput.value).toBe('hello_script');
});
expect(kindSelect.value).toBe('utility');
expect(entrypointInput.value).toBe('render');
expect(enabledInput.checked).toBe(true);
expect(screen.getByText(/Created:/)).toBeInTheDocument();
expect(screen.getByText(/Updated:/)).toBeInTheDocument();
});
it('saves renamed script metadata and content', async () => {
const updateMock = vi.fn().mockResolvedValue({
id: 'script-1',
projectId: 'default',
slug: 'my_helper_function',
title: 'My Helper Function',
kind: 'utility',
entrypoint: 'render',
enabled: true,
version: 2,
filePath: '/tmp/hello-script.py',
content: 'print("renamed")',
createdAt: '2026-02-22T00:00:00.000Z',
updatedAt: '2026-02-22T00:01:00.000Z',
});
(window as any).electronAPI.scripts.update = updateMock;
render(<ScriptsView scriptId="script-1" />);
const titleInput = await screen.findByLabelText('Title');
const kindSelect = screen.getByLabelText('Kind');
const entrypointInput = screen.getByLabelText('Entrypoint');
const enabledInput = screen.getByLabelText('Enabled');
const textarea = screen.getByLabelText('Script Content');
await vi.waitFor(() => {
expect((titleInput as HTMLInputElement).value).toBe('Hello Script');
});
fireEvent.change(kindSelect, { target: { value: 'transform' } });
fireEvent.click(enabledInput);
fireEvent.change(textarea, { target: { value: 'print("renamed")' } });
await vi.waitFor(() => {
expect((kindSelect as HTMLSelectElement).value).toBe('transform');
expect((enabledInput as HTMLInputElement).checked).toBe(false);
});
fireEvent.click(screen.getByRole('button', { name: 'Save Script' }));
await vi.waitFor(() => {
expect(updateMock).toHaveBeenCalledWith(
'script-1',
expect.objectContaining({
title: 'Hello Script',
slug: 'hello_script',
kind: 'transform',
entrypoint: 'render',
enabled: false,
content: 'print("hello")',
}),
);
});
}); });
it('runs selected script and writes output into panel output log', async () => { it('runs selected script and writes output into panel output log', async () => {
@@ -77,4 +178,23 @@ describe('ScriptsView', () => {
expect(state.panelOutputEntries.length).toBeGreaterThan(0); expect(state.panelOutputEntries.length).toBeGreaterThan(0);
expect(state.panelOutputEntries[state.panelOutputEntries.length - 1].message).toContain('hello'); expect(state.panelOutputEntries[state.panelOutputEntries.length - 1].message).toContain('hello');
}); });
it('deletes script from editor action', async () => {
const deleteMock = vi.fn().mockResolvedValue(true);
(window as any).electronAPI.scripts.delete = deleteMock;
useAppStore.setState({
tabs: [{ type: 'scripts', id: 'script-1', isTransient: false }],
activeTabId: 'script-1',
});
render(<ScriptsView scriptId="script-1" />);
fireEvent.click(await screen.findByRole('button', { name: 'Delete Script' }));
await vi.waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith('script-1');
expect(useAppStore.getState().tabs).toEqual([]);
});
});
}); });

View File

@@ -8,6 +8,21 @@ describe('Sidebar scripts list behavior', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
const listeners = new Map<string, Set<(event: Event) => void>>();
(window as any).addEventListener = vi.fn((type: string, listener: (event: Event) => void) => {
if (!listeners.has(type)) {
listeners.set(type, new Set());
}
listeners.get(type)?.add(listener);
});
(window as any).removeEventListener = vi.fn((type: string, listener: (event: Event) => void) => {
listeners.get(type)?.delete(listener);
});
(window as any).dispatchEvent = vi.fn((event: Event) => {
listeners.get(event.type)?.forEach((listener) => listener(event));
return true;
});
(window as any).electronAPI = { (window as any).electronAPI = {
...(window as any).electronAPI, ...(window as any).electronAPI,
scripts: { scripts: {
@@ -43,9 +58,11 @@ describe('Sidebar scripts list behavior', () => {
}); });
it('opens a transient script tab on single click', async () => { it('opens a transient script tab on single click', async () => {
render(<Sidebar />); const { container } = render(<Sidebar />);
const scriptRow = await screen.findByRole('button', { name: 'Hello Script' }); const scriptRow = await screen.findByRole('button', { name: 'Hello Script' });
expect(scriptRow).toHaveClass('chat-list-item');
expect(container.querySelector('.chat-item-date')).not.toBeNull();
fireEvent.click(scriptRow); fireEvent.click(scriptRow);
expect(useAppStore.getState().tabs).toEqual([ expect(useAppStore.getState().tabs).toEqual([
@@ -144,4 +161,70 @@ describe('Sidebar scripts list behavior', () => {
]); ]);
expect(useAppStore.getState().activeTabId).toBe('script-1'); expect(useAppStore.getState().activeTabId).toBe('script-1');
}); });
it('deletes a script from sidebar action', async () => {
const deleteMock = vi.fn().mockResolvedValue(true);
(window as any).electronAPI.scripts.delete = deleteMock;
useAppStore.setState({
tabs: [{ type: 'scripts', id: 'script-1', isTransient: false }],
activeTabId: 'script-1',
});
render(<Sidebar />);
const deleteButton = await screen.findByTitle('Delete script');
fireEvent.click(deleteButton);
await vi.waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith('script-1');
expect(useAppStore.getState().tabs).toEqual([]);
});
});
it('refreshes scripts list when scripts-changed event is emitted', async () => {
const getAllMock = vi
.fn()
.mockResolvedValueOnce([
{
id: 'script-1',
projectId: 'default',
slug: 'hello_script',
title: 'Hello Script',
kind: 'utility',
entrypoint: 'render',
enabled: true,
version: 1,
filePath: '/tmp/hello-script.py',
content: 'print("hello")',
createdAt: '2026-02-22T00:00:00.000Z',
updatedAt: '2026-02-22T00:00:00.000Z',
},
])
.mockResolvedValueOnce([
{
id: 'script-1',
projectId: 'default',
slug: 'renamed_script',
title: 'Renamed Script',
kind: 'utility',
entrypoint: 'render',
enabled: true,
version: 2,
filePath: '/tmp/hello-script.py',
content: 'print("hello")',
createdAt: '2026-02-22T00:00:00.000Z',
updatedAt: '2026-02-22T00:01:00.000Z',
},
]);
(window as any).electronAPI.scripts.getAll = getAllMock;
render(<Sidebar />);
await screen.findByRole('button', { name: 'Hello Script' });
window.dispatchEvent(new CustomEvent('bds:scripts-changed'));
expect(await screen.findByRole('button', { name: 'Renamed Script' })).toBeInTheDocument();
});
}); });