feat: more phase 1 implementation - proper parity now
This commit is contained in:
@@ -94,7 +94,11 @@ export class ScriptEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
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 nextFilePath = this.getScriptFilePath(nextSlug);
|
||||
const now = new Date();
|
||||
@@ -228,8 +232,8 @@ export class ScriptEngine extends EventEmitter {
|
||||
private normalizeSlug(value: string): string {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
return normalized || 'script';
|
||||
}
|
||||
|
||||
@@ -246,11 +250,11 @@ export class ScriptEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
let suffix = 2;
|
||||
while (taken.has(`${baseSlug}-${suffix}`)) {
|
||||
while (taken.has(`${baseSlug}_${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return `${baseSlug}-${suffix}`;
|
||||
return `${baseSlug}_${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,54 @@
|
||||
.scripts-view {
|
||||
.scripts-view-shell {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scripts-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.scripts-view {
|
||||
flex: 1;
|
||||
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;
|
||||
}
|
||||
|
||||
.scripts-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.scripts-label {
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.scripts-textarea {
|
||||
width: 100%;
|
||||
.scripts-editor {
|
||||
flex: 1;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,46 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import MonacoEditor from '@monaco-editor/react';
|
||||
import type { ScriptData } from '../../../main/shared/electronApi';
|
||||
import { useAppStore } from '../../store';
|
||||
import { getPythonRuntimeManager } from '../../python/runtimeManagerInstance';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { showToast } from '../Toast';
|
||||
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 {
|
||||
scriptId: string | null;
|
||||
}
|
||||
|
||||
export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
||||
const { t } = useI18n();
|
||||
const { t, language } = useI18n();
|
||||
const appendPanelOutputEntry = useAppStore((state) => state.appendPanelOutputEntry);
|
||||
const closeTab = useAppStore((state) => state.closeTab);
|
||||
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 [isSlugManuallyEdited, setIsSlugManuallyEdited] = 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(() => {
|
||||
let cancelled = false;
|
||||
@@ -22,19 +48,38 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
||||
const loadScript = async () => {
|
||||
if (!scriptId) {
|
||||
setScript(null);
|
||||
setTitle('');
|
||||
setSlug('');
|
||||
setKind('utility');
|
||||
setEntrypoint('render');
|
||||
setEnabled(true);
|
||||
setScriptContent('');
|
||||
setIsSlugManuallyEdited(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = await window.electronAPI?.scripts.get(scriptId);
|
||||
if (cancelled || !item) {
|
||||
setScript(null);
|
||||
setTitle('');
|
||||
setSlug('');
|
||||
setKind('utility');
|
||||
setEntrypoint('render');
|
||||
setEnabled(true);
|
||||
setScriptContent('');
|
||||
setIsSlugManuallyEdited(false);
|
||||
return;
|
||||
}
|
||||
|
||||
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 || '');
|
||||
const normalizedExisting = toFunctionSlug(item.slug || item.title || '');
|
||||
setIsSlugManuallyEdited(normalizedExisting !== toFunctionSlug(item.title || ''));
|
||||
};
|
||||
|
||||
void loadScript();
|
||||
@@ -44,6 +89,101 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ 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 () => {
|
||||
if (!script || isRunning) {
|
||||
return;
|
||||
@@ -90,24 +230,153 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scripts-view">
|
||||
<div className="scripts-editor">
|
||||
<div className="scripts-toolbar">
|
||||
<button type="button" onClick={handleRunScript} disabled={!script || isRunning}>
|
||||
<div className="scripts-view-shell">
|
||||
<div className="editor-header scripts-header">
|
||||
<div className="editor-tabs">
|
||||
<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')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary danger"
|
||||
onClick={handleDeleteScript}
|
||||
disabled={!script}
|
||||
title={t('scripts.delete')}
|
||||
>
|
||||
{t('scripts.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-content scripts-view">
|
||||
<div className="editor-header-row scripts-meta-row">
|
||||
<div className="editor-meta">
|
||||
<div className="editor-field-row">
|
||||
<div className="editor-field">
|
||||
<label htmlFor="script-title">{t('editor.field.title')}</label>
|
||||
<input
|
||||
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}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<label className="scripts-label" htmlFor="scripts-content">
|
||||
{t('scripts.content')}
|
||||
</label>
|
||||
<textarea
|
||||
id="scripts-content"
|
||||
className="scripts-textarea"
|
||||
value={scriptContent}
|
||||
onChange={(event) => setScriptContent(event.target.value)}
|
||||
disabled={!script}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1667,9 +1667,9 @@ const ImportList: React.FC = () => {
|
||||
};
|
||||
|
||||
const ScriptsList: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { openTab, activeTabId } = useAppStore();
|
||||
const [scripts, setScripts] = useState<Array<{ id: string; title: string }>>([]);
|
||||
const { t, language } = useI18n();
|
||||
const { openTab, activeTabId, closeTab } = useAppStore();
|
||||
const [scripts, setScripts] = useState<Array<{ id: string; title: string; updatedAt: string }>>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadScripts = useCallback(async () => {
|
||||
@@ -1678,7 +1678,7 @@ const ScriptsList: React.FC = () => {
|
||||
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(() => {
|
||||
@@ -1692,7 +1692,7 @@ const ScriptsList: React.FC = () => {
|
||||
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 {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
@@ -1702,10 +1702,22 @@ const ScriptsList: React.FC = () => {
|
||||
|
||||
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 () => {
|
||||
cancelled = true;
|
||||
if (canListen) {
|
||||
window.removeEventListener('bds:scripts-changed', handleScriptsChanged);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [loadScripts]);
|
||||
|
||||
const handleCreateScript = async () => {
|
||||
try {
|
||||
@@ -1722,9 +1734,12 @@ const ScriptsList: React.FC = () => {
|
||||
}
|
||||
|
||||
setScripts((prev) => [
|
||||
{ id: created.id, title: created.title },
|
||||
{ id: created.id, title: created.title, updatedAt: created.updatedAt },
|
||||
...prev.filter((script) => script.id !== created.id),
|
||||
]);
|
||||
if (typeof window.dispatchEvent === 'function') {
|
||||
window.dispatchEvent(new CustomEvent('bds:scripts-changed'));
|
||||
}
|
||||
openScriptTab(openTab, created.id, 'pin');
|
||||
void loadScripts();
|
||||
} 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) {
|
||||
return (
|
||||
<div className="chat-list">
|
||||
@@ -1767,17 +1816,37 @@ const ScriptsList: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
scripts.map((script) => (
|
||||
<button
|
||||
<div
|
||||
key={script.id}
|
||||
type="button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={script.title}
|
||||
className={`chat-list-item ${activeTabId === script.id ? 'active' : ''}`}
|
||||
onClick={() => openScriptTab(openTab, script.id, 'preview')}
|
||||
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-title">{script.title}</div>
|
||||
<div className="chat-item-date">{formatDate(script.updatedAt)}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
className="chat-item-delete"
|
||||
onClick={(event) => handleDeleteScript(event, script.id)}
|
||||
title={t('sidebar.scripts.deleteScript')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -419,7 +419,15 @@
|
||||
"sidebar.nav.style": "Stil",
|
||||
"sidebar.nav.scripts": "Skripte",
|
||||
"scripts.run": "Skript ausführen",
|
||||
"scripts.save": "Skript speichern",
|
||||
"scripts.delete": "Skript löschen",
|
||||
"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.createEdit": "Erstellen & Bearbeiten",
|
||||
"sidebar.mergeTags": "Tags zusammenführen",
|
||||
@@ -705,6 +713,8 @@
|
||||
"sidebar.scripts.none": "Noch keine Skripte",
|
||||
"sidebar.scripts.createScript": "Ein Skript erstellen",
|
||||
"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.createDefinition": "Eine Importdefinition erstellen",
|
||||
"sidebar.import.deleteDefinition": "Importdefinition löschen",
|
||||
|
||||
@@ -419,7 +419,15 @@
|
||||
"sidebar.nav.style": "Style",
|
||||
"sidebar.nav.scripts": "Scripts",
|
||||
"scripts.run": "Run Script",
|
||||
"scripts.save": "Save Script",
|
||||
"scripts.delete": "Delete Script",
|
||||
"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.createEdit": "Create & Edit",
|
||||
"sidebar.mergeTags": "Merge Tags",
|
||||
@@ -705,6 +713,8 @@
|
||||
"sidebar.scripts.none": "No scripts yet",
|
||||
"sidebar.scripts.createScript": "Create a 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.createDefinition": "Create an import definition",
|
||||
"sidebar.import.deleteDefinition": "Delete import definition",
|
||||
|
||||
@@ -419,7 +419,15 @@
|
||||
"sidebar.nav.style": "Estilo",
|
||||
"sidebar.nav.scripts": "Scripts",
|
||||
"scripts.run": "Ejecutar script",
|
||||
"scripts.save": "Guardar script",
|
||||
"scripts.delete": "Eliminar 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.createEdit": "Crear y editar",
|
||||
"sidebar.mergeTags": "Combinar etiquetas",
|
||||
@@ -705,6 +713,8 @@
|
||||
"sidebar.scripts.none": "Aún no hay scripts",
|
||||
"sidebar.scripts.createScript": "Crear un 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.createDefinition": "Crear definición",
|
||||
"sidebar.import.deleteDefinition": "Eliminar definición",
|
||||
|
||||
@@ -419,7 +419,15 @@
|
||||
"sidebar.nav.style": "Style",
|
||||
"sidebar.nav.scripts": "Scripts",
|
||||
"scripts.run": "Exécuter le script",
|
||||
"scripts.save": "Enregistrer le script",
|
||||
"scripts.delete": "Supprimer le script",
|
||||
"scripts.content": "Contenu du script",
|
||||
"scripts.field.kind": "Type",
|
||||
"scripts.field.entrypoint": "Point d’entrée",
|
||||
"scripts.field.enabled": "Activé",
|
||||
"scripts.kind.utility": "utility",
|
||||
"scripts.kind.macro": "macro",
|
||||
"scripts.kind.transform": "transform",
|
||||
"sidebar.tagCloud": "Nuage d’étiquettes",
|
||||
"sidebar.createEdit": "Créer & modifier",
|
||||
"sidebar.mergeTags": "Fusionner les étiquettes",
|
||||
@@ -705,6 +713,8 @@
|
||||
"sidebar.scripts.none": "Aucun script",
|
||||
"sidebar.scripts.createScript": "Créer un 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 d’import",
|
||||
"sidebar.import.createDefinition": "Créer une définition",
|
||||
"sidebar.import.deleteDefinition": "Supprimer la définition",
|
||||
|
||||
@@ -419,7 +419,15 @@
|
||||
"sidebar.nav.style": "Stile",
|
||||
"sidebar.nav.scripts": "Script",
|
||||
"scripts.run": "Esegui script",
|
||||
"scripts.save": "Salva script",
|
||||
"scripts.delete": "Elimina 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.createEdit": "Crea e modifica",
|
||||
"sidebar.mergeTags": "Unisci tag",
|
||||
@@ -705,6 +713,8 @@
|
||||
"sidebar.scripts.none": "Nessuno script",
|
||||
"sidebar.scripts.createScript": "Crea uno 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.createDefinition": "Crea definizione",
|
||||
"sidebar.import.deleteDefinition": "Elimina definizione",
|
||||
|
||||
@@ -100,9 +100,9 @@ describe('ScriptEngine', () => {
|
||||
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(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 () => {
|
||||
@@ -117,8 +117,29 @@ describe('ScriptEngine', () => {
|
||||
content: 'def render(context):\n return {"html": "<h1>Banner</h1>"}',
|
||||
});
|
||||
|
||||
expect(updated?.slug).toBe('render-hero-banner');
|
||||
expect(mockFiles.get('/mock/userData/projects/default/scripts/render-hero-banner.py')).toContain('Banner');
|
||||
expect(updated?.slug).toBe('render_hero_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 () => {
|
||||
@@ -132,6 +153,6 @@ describe('ScriptEngine', () => {
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,6 @@ describe('ScriptsView styles', () => {
|
||||
const css = fs.readFileSync(cssPath, 'utf8');
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,24 @@ import { ScriptsView } from '../../../src/renderer/components/ScriptsView/Script
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
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', () => ({
|
||||
getPythonRuntimeManager: () => ({
|
||||
@@ -59,6 +77,89 @@ describe('ScriptsView', () => {
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'print("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 () => {
|
||||
@@ -77,4 +178,23 @@ describe('ScriptsView', () => {
|
||||
expect(state.panelOutputEntries.length).toBeGreaterThan(0);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,21 @@ describe('Sidebar scripts list behavior', () => {
|
||||
beforeEach(() => {
|
||||
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,
|
||||
scripts: {
|
||||
@@ -43,9 +58,11 @@ describe('Sidebar scripts list behavior', () => {
|
||||
});
|
||||
|
||||
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' });
|
||||
expect(scriptRow).toHaveClass('chat-list-item');
|
||||
expect(container.querySelector('.chat-item-date')).not.toBeNull();
|
||||
fireEvent.click(scriptRow);
|
||||
|
||||
expect(useAppStore.getState().tabs).toEqual([
|
||||
@@ -144,4 +161,70 @@ describe('Sidebar scripts list behavior', () => {
|
||||
]);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user