feat: phase 1 of python scripting

This commit is contained in:
2026-02-22 22:12:30 +01:00
parent ce050f98c3
commit 3ec8819d6d
43 changed files with 2329 additions and 14 deletions

View File

@@ -30,6 +30,12 @@ const MediaIcon = () => (
</svg>
);
const ScriptsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"/>
</svg>
);
const SettingsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
@@ -170,6 +176,13 @@ export const ActivityBar: React.FC = () => {
>
<MediaIcon />
</button>
<button
className={`activity-bar-item ${isActivityActive(snapshot, 'scripts') ? 'active' : ''}`}
onClick={() => executeActivityClick('scripts')}
title={getTitle('scripts')}
>
<ScriptsIcon />
</button>
<button
className={`activity-bar-item ${isActivityActive(snapshot, 'tags') ? 'active' : ''}`}
onClick={() => executeActivityClick('tags')}

View File

@@ -19,6 +19,7 @@ import { MetadataDiffPanel } from '../MetadataDiffPanel';
import { GitDiffView } from '../GitDiffView/GitDiffView';
import { DocumentationView } from '../DocumentationView/DocumentationView';
import { SiteValidationView } from '../SiteValidationView';
import { ScriptsView } from '../ScriptsView/ScriptsView';
import { AutoSaveManager, getContrastColor } from '../../utils';
import { InsertModal } from '../InsertModal';
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
@@ -1796,6 +1797,7 @@ export const Editor: React.FC = () => {
: <Dashboard />,
documentation: () => <DocumentationView />,
'site-validation': () => <SiteValidationView />,
scripts: () => <ScriptsView scriptId={editorRoute.tabId} />,
post: () => (editorRoute.tabId ? <PostEditor key={editorRoute.tabId} postId={editorRoute.tabId} /> : <Dashboard />),
media: () => (editorRoute.tabId ? <MediaEditor key={editorRoute.tabId} mediaId={editorRoute.tabId} /> : <Dashboard />),
dashboard: () => <Dashboard />,

View File

@@ -86,6 +86,22 @@
gap: 4px;
}
.output-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.output-item {
background-color: var(--vscode-sideBar-background);
border-radius: 4px;
padding: 8px;
font-size: 12px;
color: var(--vscode-editor-foreground);
white-space: pre-wrap;
word-break: break-word;
}
.task-group-row {
display: flex;
flex-direction: column;

View File

@@ -43,6 +43,7 @@ export const Panel: React.FC = () => {
panelVisible,
panelActiveTab,
setPanelActiveTab,
panelOutputEntries,
tasks,
tabs,
activeTabId,
@@ -383,7 +384,17 @@ export const Panel: React.FC = () => {
)}
{effectiveActivePanelTab === 'output' && (
<div className="panel-empty">{t('panel.noOutput')}</div>
panelOutputEntries.length === 0 ? (
<div className="panel-empty">{t('panel.noOutput')}</div>
) : (
<div className="output-list">
{panelOutputEntries.map((entry) => (
<div key={entry.id} className={`output-item output-${entry.kind}`}>
{entry.message}
</div>
))}
</div>
)
)}
{effectiveActivePanelTab === 'post-links' && (

View File

@@ -0,0 +1,32 @@
.scripts-view {
display: flex;
flex: 1;
min-height: 0;
width: 100%;
}
.scripts-editor {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
padding: 10px;
gap: 8px;
}
.scripts-toolbar {
display: flex;
justify-content: flex-end;
}
.scripts-label {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.scripts-textarea {
width: 100%;
flex: 1;
min-height: 0;
}

View File

@@ -0,0 +1,114 @@
import React, { useEffect, useState } from 'react';
import type { ScriptData } from '../../../main/shared/electronApi';
import { useAppStore } from '../../store';
import { getPythonRuntimeManager } from '../../python/runtimeManagerInstance';
import { useI18n } from '../../i18n';
import './ScriptsView.css';
interface ScriptsViewProps {
scriptId: string | null;
}
export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
const { t } = useI18n();
const appendPanelOutputEntry = useAppStore((state) => state.appendPanelOutputEntry);
const [script, setScript] = useState<ScriptData | null>(null);
const [scriptContent, setScriptContent] = useState('');
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
let cancelled = false;
const loadScript = async () => {
if (!scriptId) {
setScript(null);
setScriptContent('');
return;
}
const item = await window.electronAPI?.scripts.get(scriptId);
if (cancelled || !item) {
setScript(null);
setScriptContent('');
return;
}
setScript(item);
setScriptContent(item.content || '');
};
void loadScript();
return () => {
cancelled = true;
};
}, [scriptId]);
const handleRunScript = async () => {
if (!script || isRunning) {
return;
}
setIsRunning(true);
try {
const runtimeManager = getPythonRuntimeManager();
const result = await runtimeManager.execute(scriptContent);
const now = new Date().toISOString();
if (result.result.trim().length > 0) {
appendPanelOutputEntry({
id: `output-${Date.now()}-result`,
message: result.result,
createdAt: now,
kind: 'result',
});
}
if (result.stdout.trim().length > 0) {
appendPanelOutputEntry({
id: `output-${Date.now()}-stdout`,
message: result.stdout,
createdAt: now,
kind: 'stdout',
});
}
} catch (error) {
appendPanelOutputEntry({
id: `output-${Date.now()}-error`,
message: error instanceof Error ? error.message : String(error),
createdAt: new Date().toISOString(),
kind: 'error',
});
} finally {
useAppStore.setState({
panelVisible: true,
panelActiveTab: 'output',
});
setIsRunning(false);
}
};
return (
<div className="scripts-view">
<div className="scripts-editor">
<div className="scripts-toolbar">
<button type="button" onClick={handleRunScript} disabled={!script || isRunning}>
{t('scripts.run')}
</button>
</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>
</div>
);
};

View File

@@ -8,7 +8,7 @@ import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/Setti
import { scrollToTagsSection, TagsCategory } from '../TagsView';
import { activateSidebarSection } from '../../navigation/sectionActivation';
import { getPersistedSidebarSection, setPersistedSidebarSection } from '../../navigation/sidebarUiPersistence';
import { openChatTab, openEntityTab, openImportTab, openSingletonToolTab } from '../../navigation/tabPolicy';
import { openChatTab, openEntityTab, openImportTab, openScriptTab, openSingletonToolTab } from '../../navigation/tabPolicy';
import { createAndFocusPost } from '../../navigation/postCreation';
import type { SidebarView } from '../../navigation/sidebarViewRegistry';
import { useI18n } from '../../i18n';
@@ -1666,6 +1666,125 @@ 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 [isLoading, setIsLoading] = useState(true);
const loadScripts = useCallback(async () => {
const items = await window.electronAPI?.scripts.getAll();
if (!items) {
return;
}
setScripts(items.map((item) => ({ id: item.id, title: item.title })));
}, []);
useEffect(() => {
let cancelled = false;
const loadInitialScripts = async () => {
setIsLoading(true);
try {
const items = await window.electronAPI?.scripts.getAll();
if (cancelled) {
return;
}
setScripts((items ?? []).map((item) => ({ id: item.id, title: item.title })));
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
};
void loadInitialScripts();
return () => {
cancelled = true;
};
}, []);
const handleCreateScript = async () => {
try {
const created = await window.electronAPI?.scripts.create({
title: t('sidebar.scripts.newScript'),
kind: 'utility',
content: 'print("new script")',
entrypoint: 'render',
enabled: true,
});
if (!created) {
return;
}
setScripts((prev) => [
{ id: created.id, title: created.title },
...prev.filter((script) => script.id !== created.id),
]);
openScriptTab(openTab, created.id, 'pin');
void loadScripts();
} catch (error) {
console.error('Failed to create script:', error);
showToast.error(t('sidebar.scripts.createFailed'));
}
};
if (isLoading) {
return (
<div className="chat-list">
<div className="chat-list-header">
<span>{t('sidebar.scripts.header')}</span>
</div>
<div className="chat-loading">{t('sidebar.loading')}</div>
</div>
);
}
return (
<div className="chat-list">
<div className="chat-list-header">
<span>{t('sidebar.scripts.header')}</span>
<button
className="chat-new-button"
onClick={handleCreateScript}
aria-label={t('sidebar.scripts.newScript')}
title={t('sidebar.scripts.newScript')}
>
+
</button>
</div>
<div className="chat-list-items">
{scripts.length === 0 ? (
<div className="chat-empty">
<p>{t('sidebar.scripts.none')}</p>
<button className="chat-start-button" onClick={handleCreateScript}>
{t('sidebar.scripts.createScript')}
</button>
</div>
) : (
scripts.map((script) => (
<button
key={script.id}
type="button"
className={`chat-list-item ${activeTabId === script.id ? 'active' : ''}`}
onClick={() => openScriptTab(openTab, script.id, 'preview')}
onDoubleClick={() => openScriptTab(openTab, script.id, 'pin')}
>
<div className="chat-item-content">
<div className="chat-item-title">{script.title}</div>
</div>
</button>
))
)}
</div>
</div>
);
};
export const Sidebar: React.FC = () => {
const { activeView, sidebarVisible } = useAppStore();
@@ -1677,6 +1796,7 @@ export const Sidebar: React.FC = () => {
posts: <PostsList mode="posts" isActive={true} />,
pages: <PostsList mode="pages" isActive={true} />,
media: <MediaList />,
scripts: <ScriptsList />,
settings: <SettingsNav />,
tags: <TagsNav />,
chat: <ChatList />,

View File

@@ -80,6 +80,10 @@ const getTabTitle = (
return tr('siteValidation.tabTitle');
}
if (tab.type === 'scripts') {
return tr('tabBar.scripts');
}
return tr('tabBar.unknown');
};
@@ -158,6 +162,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
<path d="M8 1.5a6.5 6.5 0 1 0 6.5 6.5A6.5 6.5 0 0 0 8 1.5zm0 1a5.5 5.5 0 0 1 4.39 8.82l-.88-.88a.5.5 0 0 0-.7.7l.8.8A5.5 5.5 0 1 1 8 2.5zm2.35 3.15L7 9 5.65 7.65a.5.5 0 1 0-.7.7l1.7 1.7a.5.5 0 0 0 .7 0l3.7-3.7a.5.5 0 1 0-.7-.7z"/>
</svg>
);
case 'scripts':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"/>
</svg>
);
default:
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">

View File

@@ -26,3 +26,4 @@ export { InsertModal } from './InsertModal';
export { WindowTitleBar } from './WindowTitleBar';
export { DocumentationView } from './DocumentationView/DocumentationView';
export { SiteValidationView } from './SiteValidationView';
export { ScriptsView } from './ScriptsView/ScriptsView';