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 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}`;
}
}

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 dentré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 dimport",
"sidebar.import.createDefinition": "Créer une définition",
"sidebar.import.deleteDefinition": "Supprimer la définition",

View File

@@ -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",