feat: more phase 1 implementation - proper parity now
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user