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

@@ -108,10 +108,10 @@ Baseline benchmark (22 Feb 2026, local macOS run):
Objective: user can create/run scripts and see output.
Deliverables:
- [ ] Script storage model (DB index + filesystem source in `scripts/*.py`).
- [ ] CRUD APIs in `main/engine` + `ipc` handlers.
- [ ] Renderer scripts list + editor + run button.
- [ ] Console/output capture in existing bottom output area.
- [x] Script storage model (DB index + filesystem source in `scripts/*.py`).
- [x] CRUD APIs in `main/engine` + `ipc` handlers.
- [x] Renderer scripts list + editor + run button.
- [x] Console/output capture in existing bottom output area.
- [ ] Project rebuild picks up `scripts/` changes.
Out of scope for MVP:
@@ -454,15 +454,18 @@ PR-14+: Optional advanced capabilities
## 10. Current Status
Status: Phase 0 in progress (MVP-first, full-scope preserved).
Status: Phase 1 in progress (MVP-first, full-scope preserved).
Progress update (22 Feb 2026):
- [x] PR-00 complete: Pyodide dependency + renderer worker bootstrap + ready signal.
- [x] PR-01 complete: worker run/stdout/error protocol + timeout watchdog + runtime recovery.
- [x] PR-02 complete: ABI v1 shared types/schemas + caller/worker validation.
- [x] Phase 0 benchmark harness + baseline capture complete.
- [x] PR-03 complete: scripts table + `ScriptEngine` filesystem/DB round-trip persistence.
- [x] PR-04 complete: script CRUD IPC handlers + preload/shared API typing + IPC tests.
- [x] PR-05 complete: renderer scripts list/editor/run flow + output panel integration.
Recommended next action:
1. Start Phase 1 PR-03: script persistence model (`scripts/*.py` + index metadata).
2. Add round-trip tests for create/update/delete between filesystem and DB.
1. Start Phase 1 PR-06: include `scripts/` in rebuild/meta-diff synchronization.
2. Keep scripts API access in renderer views/store paths only (no deep component IPC sprawl).
3. Keep benchmark command in CI/manual perf checks for regressions.

View File

@@ -0,0 +1,15 @@
CREATE TABLE `scripts` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`slug` text NOT NULL,
`title` text NOT NULL,
`kind` text DEFAULT 'utility' NOT NULL,
`entrypoint` text DEFAULT 'render' NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`version` integer DEFAULT 1 NOT NULL,
`file_path` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `scripts_project_slug_idx` ON `scripts` (`project_id`,`slug`);

View File

@@ -0,0 +1,913 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b157a762-0743-4499-a635-16ac3fb5ee18",
"prevId": "46702982-9f8a-4c7e-8fb6-3270c3fbe120",
"tables": {
"chat_conversations": {
"name": "chat_conversations",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"copilot_session_id": {
"name": "copilot_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chat_messages": {
"name": "chat_messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tool_call_id": {
"name": "tool_call_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tool_calls": {
"name": "tool_calls",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"generated_file_hashes": {
"name": "generated_file_hashes",
"columns": {
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"relative_path": {
"name": "relative_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_hash": {
"name": "content_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"generated_file_hashes_project_path_idx": {
"name": "generated_file_hashes_project_path_idx",
"columns": [
"project_id",
"relative_path"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"import_definitions": {
"name": "import_definitions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"wxr_file_path": {
"name": "wxr_file_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"uploads_folder_path": {
"name": "uploads_folder_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_analysis_result": {
"name": "last_analysis_result",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"media": {
"name": "media",
"columns": {
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"original_name": {
"name": "original_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"alt": {
"name": "alt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caption": {
"name": "caption",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sidecar_path": {
"name": "sidecar_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"post_links": {
"name": "post_links",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"source_post_id": {
"name": "source_post_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_post_id": {
"name": "target_post_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"link_text": {
"name": "link_text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"post_media": {
"name": "post_media",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"post_id": {
"name": "post_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"media_id": {
"name": "media_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"post_media_post_media_idx": {
"name": "post_media_post_media_idx",
"columns": [
"post_id",
"media_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"posts": {
"name": "posts",
"columns": {
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"excerpt": {
"name": "excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"categories": {
"name": "categories",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_title": {
"name": "published_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_content": {
"name": "published_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_tags": {
"name": "published_tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_categories": {
"name": "published_categories",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_excerpt": {
"name": "published_excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"posts_project_slug_idx": {
"name": "posts_project_slug_idx",
"columns": [
"project_id",
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"data_path": {
"name": "data_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"projects_slug_unique": {
"name": "projects_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"scripts": {
"name": "scripts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'utility'"
},
"entrypoint": {
"name": "entrypoint",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'render'"
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"scripts_project_slug_idx": {
"name": "scripts_project_slug_idx",
"columns": [
"project_id",
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tags_project_name_idx": {
"name": "tags_project_name_idx",
"columns": [
"project_id",
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -36,6 +36,13 @@
"when": 1771605253203,
"tag": "0004_overjoyed_paper_doll",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1771792324840,
"tag": "0005_short_sally_floyd",
"breakpoints": true
}
]
}

View File

@@ -151,6 +151,24 @@ export const importDefinitions = sqliteTable('import_definitions', {
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Scripts table - stores metadata for Python scripts persisted in scripts/*.py
export const scripts = sqliteTable('scripts', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(),
slug: text('slug').notNull(),
title: text('title').notNull(),
kind: text('kind', { enum: ['macro', 'utility', 'transform'] }).notNull().default('utility'),
entrypoint: text('entrypoint').notNull().default('render'),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
version: integer('version').notNull().default(1),
filePath: text('file_path').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
// Composite unique index: slug must be unique within each project
projectSlugIdx: uniqueIndex('scripts_project_slug_idx').on(table.projectId, table.slug),
}));
// Types for TypeScript
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;
@@ -174,3 +192,5 @@ export type ChatMessage = typeof chatMessages.$inferSelect;
export type NewChatMessage = typeof chatMessages.$inferInsert;
export type ImportDefinition = typeof importDefinitions.$inferSelect;
export type NewImportDefinition = typeof importDefinitions.$inferInsert;
export type Script = typeof scripts.$inferSelect;
export type NewScript = typeof scripts.$inferInsert;

View File

@@ -0,0 +1,264 @@
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs/promises';
import * as path from 'path';
import { app } from 'electron';
import { and, desc, eq } from 'drizzle-orm';
import { getDatabase } from '../database';
import { scripts, type NewScript, type Script } from '../database/schema';
export type ScriptKind = 'macro' | 'utility' | 'transform';
export interface ScriptData {
id: string;
projectId: string;
slug: string;
title: string;
kind: ScriptKind;
entrypoint: string;
enabled: boolean;
version: number;
filePath: string;
content: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreateScriptInput {
title: string;
kind: ScriptKind;
content: string;
slug?: string;
entrypoint?: string;
enabled?: boolean;
}
export interface UpdateScriptInput {
title?: string;
kind?: ScriptKind;
content?: string;
slug?: string;
entrypoint?: string;
enabled?: boolean;
}
export class ScriptEngine extends EventEmitter {
private currentProjectId = 'default';
private dataDir: string | null = null;
setProjectContext(projectId: string, dataDir?: string): void {
this.currentProjectId = projectId;
this.dataDir = dataDir || null;
}
getProjectContext(): string {
return this.currentProjectId;
}
async createScript(input: CreateScriptInput): Promise<ScriptData> {
const now = new Date();
const allScripts = await this.getAllScriptRows();
const desiredSlug = this.normalizeSlug(input.slug || input.title || 'script');
const uniqueSlug = this.ensureUniqueSlug(desiredSlug, allScripts);
const scriptId = uuidv4();
const filePath = this.getScriptFilePath(uniqueSlug);
await fs.mkdir(this.getScriptsDir(), { recursive: true });
await fs.writeFile(filePath, input.content, 'utf-8');
const row: NewScript = {
id: scriptId,
projectId: this.currentProjectId,
slug: uniqueSlug,
title: input.title,
kind: input.kind,
entrypoint: input.entrypoint || 'render',
enabled: input.enabled ?? true,
version: 1,
filePath,
createdAt: now,
updatedAt: now,
};
await getDatabase().getLocal().insert(scripts).values(row);
const created = await this.toScriptData(row as Script);
this.emit('scriptCreated', created);
return created;
}
async updateScript(id: string, updates: UpdateScriptInput): Promise<ScriptData | null> {
const existing = await this.getScriptRow(id);
if (!existing) {
return null;
}
const allScripts = await this.getAllScriptRows();
const desiredSlug = this.normalizeSlug(updates.slug || updates.title || existing.slug);
const nextSlug = this.ensureUniqueSlug(desiredSlug, allScripts, existing.id);
const nextFilePath = this.getScriptFilePath(nextSlug);
const now = new Date();
if (existing.filePath !== nextFilePath) {
await fs.mkdir(this.getScriptsDir(), { recursive: true });
await fs.rename(existing.filePath, nextFilePath);
}
if (typeof updates.content === 'string') {
await fs.writeFile(nextFilePath, updates.content, 'utf-8');
}
await getDatabase().getLocal()
.update(scripts)
.set({
title: updates.title ?? existing.title,
slug: nextSlug,
kind: updates.kind ?? existing.kind,
entrypoint: updates.entrypoint ?? existing.entrypoint,
enabled: updates.enabled ?? existing.enabled,
filePath: nextFilePath,
version: existing.version + 1,
updatedAt: now,
})
.where(and(eq(scripts.id, existing.id), eq(scripts.projectId, this.currentProjectId)));
const updatedRow = await this.getScriptRow(existing.id);
if (!updatedRow) {
return null;
}
const updated = await this.toScriptData(updatedRow);
this.emit('scriptUpdated', updated);
return updated;
}
async deleteScript(id: string): Promise<boolean> {
const existing = await this.getScriptRow(id);
if (!existing) {
return false;
}
await getDatabase().getLocal()
.delete(scripts)
.where(and(eq(scripts.id, existing.id), eq(scripts.projectId, this.currentProjectId)));
try {
await fs.unlink(existing.filePath);
} catch (error) {
const fsError = error as NodeJS.ErrnoException;
if (fsError.code !== 'ENOENT') {
throw error;
}
}
this.emit('scriptDeleted', id);
return true;
}
async getScript(id: string): Promise<ScriptData | null> {
const row = await this.getScriptRow(id);
if (!row) {
return null;
}
return this.toScriptData(row);
}
async getAllScripts(): Promise<ScriptData[]> {
const rows = await this.getAllScriptRows();
return Promise.all(rows.map((item) => this.toScriptData(item)));
}
private async getScriptRow(id: string): Promise<Script | null> {
const rows = await this.getAllScriptRows();
return rows.find((item) => item.id === id) || null;
}
private async getAllScriptRows(): Promise<Script[]> {
return getDatabase().getLocal()
.select()
.from(scripts)
.where(eq(scripts.projectId, this.currentProjectId))
.orderBy(desc(scripts.updatedAt))
.all();
}
private async toScriptData(row: Script): Promise<ScriptData> {
let content = '';
try {
content = await fs.readFile(row.filePath, 'utf-8');
} catch (error) {
const fsError = error as NodeJS.ErrnoException;
if (fsError.code !== 'ENOENT') {
throw error;
}
}
return {
id: row.id,
projectId: row.projectId,
slug: row.slug,
title: row.title,
kind: row.kind,
entrypoint: row.entrypoint,
enabled: row.enabled,
version: row.version,
filePath: row.filePath,
content,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private getDataDir(): string {
if (this.dataDir) {
return this.dataDir;
}
return path.join(app.getPath('userData'), 'projects', this.currentProjectId);
}
private getScriptsDir(): string {
return path.join(this.getDataDir(), 'scripts');
}
private getScriptFilePath(slug: string): string {
return path.join(this.getScriptsDir(), `${slug}.py`);
}
private normalizeSlug(value: string): string {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
return normalized || 'script';
}
private ensureUniqueSlug(slug: string, rows: Script[], excludeId?: string): string {
const baseSlug = slug;
const taken = new Set(
rows
.filter((item) => item.id !== excludeId)
.map((item) => item.slug)
);
if (!taken.has(baseSlug)) {
return baseSlug;
}
let suffix = 2;
while (taken.has(`${baseSlug}-${suffix}`)) {
suffix += 1;
}
return `${baseSlug}-${suffix}`;
}
}
let scriptEngineInstance: ScriptEngine | null = null;
export function getScriptEngine(): ScriptEngine {
if (!scriptEngineInstance) {
scriptEngineInstance = new ScriptEngine();
}
return scriptEngineInstance;
}

View File

@@ -100,3 +100,11 @@ export {
type MenuDocument,
type MenuItemKind,
} from './MenuEngine';
export {
ScriptEngine,
getScriptEngine,
type ScriptData,
type ScriptKind,
type CreateScriptInput,
type UpdateScriptInput,
} from './ScriptEngine';

View File

@@ -9,6 +9,7 @@ import { getMetaEngine } from '../engine/MetaEngine';
import { getMenuEngine, type MenuDocument } from '../engine/MenuEngine';
import { getTagEngine } from '../engine/TagEngine';
import { getPostMediaEngine } from '../engine/PostMediaEngine';
import { getScriptEngine, type CreateScriptInput, type UpdateScriptInput } from '../engine/ScriptEngine';
import { getGitEngine } from '../engine/GitEngine';
import { taskManager, TaskProgress } from '../engine/TaskManager';
import { getDatabase } from '../database';
@@ -284,11 +285,13 @@ export function registerIpcHandlers(): void {
const metaEngine = getMetaEngine();
const menuEngine = getMenuEngine();
const tagEngine = getTagEngine();
const scriptEngine = getScriptEngine();
postEngine.setProjectContext(project.id, dataDir);
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
metaEngine.setProjectContext(project.id, dataDir);
menuEngine.setProjectContext(project.id, dataDir);
tagEngine.setProjectContext(project.id, dataDir);
scriptEngine.setProjectContext(project.id, dataDir);
const postMediaEngine = getPostMediaEngine();
postMediaEngine.setProjectContext(project.id);
@@ -322,11 +325,13 @@ export function registerIpcHandlers(): void {
const metaEngine = getMetaEngine();
const menuEngine = getMenuEngine();
const tagEngine = getTagEngine();
const scriptEngine = getScriptEngine();
postEngine.setProjectContext(project.id, dataDir);
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
metaEngine.setProjectContext(project.id, dataDir);
menuEngine.setProjectContext(project.id, dataDir);
tagEngine.setProjectContext(project.id, dataDir);
scriptEngine.setProjectContext(project.id, dataDir);
const postMediaEngine = getPostMediaEngine();
postMediaEngine.setProjectContext(project.id);
@@ -723,6 +728,33 @@ export function registerIpcHandlers(): void {
return engine.regenerateMissingThumbnails();
});
// ============ Script Handlers ============
safeHandle('scripts:create', async (_, data: CreateScriptInput) => {
const engine = getScriptEngine();
return engine.createScript(data);
});
safeHandle('scripts:update', async (_, id: string, data: UpdateScriptInput) => {
const engine = getScriptEngine();
return engine.updateScript(id, data);
});
safeHandle('scripts:delete', async (_, id: string) => {
const engine = getScriptEngine();
return engine.deleteScript(id);
});
safeHandle('scripts:get', async (_, id: string) => {
const engine = getScriptEngine();
return engine.getScript(id);
});
safeHandle('scripts:getAll', async () => {
const engine = getScriptEngine();
return engine.getAllScripts();
});
// ============ Task Handlers ============
safeHandle('tasks:getAll', async () => {

View File

@@ -101,6 +101,15 @@ export const electronAPI: ElectronAPI = {
regenerateMissingThumbnails: () => ipcRenderer.invoke('media:regenerateMissingThumbnails'),
},
// Scripts
scripts: {
create: (data: { title: string; kind: import('./shared/electronApi').ScriptKind; content: string; slug?: string; entrypoint?: string; enabled?: boolean }) => ipcRenderer.invoke('scripts:create', data),
update: (id: string, data: { title?: string; kind?: import('./shared/electronApi').ScriptKind; content?: string; slug?: string; entrypoint?: string; enabled?: boolean }) => ipcRenderer.invoke('scripts:update', id, data),
delete: (id: string) => ipcRenderer.invoke('scripts:delete', id),
get: (id: string) => ipcRenderer.invoke('scripts:get', id),
getAll: () => ipcRenderer.invoke('scripts:getAll'),
},
// Post-Media Links
postMedia: {
link: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:link', postId, mediaId),

View File

@@ -133,6 +133,23 @@ export interface MediaSearchResult {
createdAt: string;
}
export type ScriptKind = 'macro' | 'utility' | 'transform';
export interface ScriptData {
id: string;
projectId: string;
slug: string;
title: string;
kind: ScriptKind;
entrypoint: string;
enabled: boolean;
version: number;
filePath: string;
content: string;
createdAt: string;
updatedAt: string;
}
export interface TaskProgress {
taskId: string;
name: string;
@@ -528,6 +545,27 @@ export interface ElectronAPI {
getTags: () => Promise<string[]>;
getTagsWithCounts: () => Promise<TagCount[]>;
};
scripts: {
create: (data: {
title: string;
kind: ScriptKind;
content: string;
slug?: string;
entrypoint?: string;
enabled?: boolean;
}) => Promise<ScriptData>;
update: (id: string, data: {
title?: string;
kind?: ScriptKind;
content?: string;
slug?: string;
entrypoint?: string;
enabled?: boolean;
}) => Promise<ScriptData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<ScriptData | null>;
getAll: () => Promise<ScriptData[]>;
};
postMedia: {
link: (postId: string, mediaId: string) => Promise<MediaLinkData>;
unlink: (postId: string, mediaId: string) => Promise<void>;

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' && (
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';

View File

@@ -9,6 +9,7 @@
"activity.posts": "Beiträge",
"activity.pages": "Seiten",
"activity.media": "Medien",
"activity.scripts": "Skripte",
"activity.tags": "Schlagwörter",
"activity.aiAssistant": "KI-Assistent",
"activity.import": "Importieren",
@@ -339,6 +340,7 @@
"gitSidebar.placeholder.commitMessage": "Commit-Nachricht",
"editor.untitled": "Unbenannt",
"tabBar.style": "Stil",
"tabBar.scripts": "Skripte",
"tabBar.loading": "Laden...",
"tabBar.unknown": "Unbekannt",
"tabBar.preview": "Vorschau",
@@ -415,6 +417,9 @@
"sidebar.nav.publishing": "Veröffentlichung",
"sidebar.nav.data": "Daten",
"sidebar.nav.style": "Stil",
"sidebar.nav.scripts": "Skripte",
"scripts.run": "Skript ausführen",
"scripts.content": "Skriptinhalt",
"sidebar.tagCloud": "Tag-Wolke",
"sidebar.createEdit": "Erstellen & Bearbeiten",
"sidebar.mergeTags": "Tags zusammenführen",
@@ -695,6 +700,11 @@
"sidebar.chat.yesterday": "Gestern",
"sidebar.import.header": "IMPORTE",
"sidebar.import.newDefinition": "Neue Importdefinition",
"sidebar.scripts.header": "SKRIPTE",
"sidebar.scripts.newScript": "Neues Skript",
"sidebar.scripts.none": "Noch keine Skripte",
"sidebar.scripts.createScript": "Ein Skript erstellen",
"sidebar.scripts.createFailed": "Skript konnte nicht erstellt werden",
"sidebar.import.none": "Noch keine Importdefinitionen",
"sidebar.import.createDefinition": "Eine Importdefinition erstellen",
"sidebar.import.deleteDefinition": "Importdefinition löschen",

View File

@@ -9,6 +9,7 @@
"activity.posts": "Posts",
"activity.pages": "Pages",
"activity.media": "Media",
"activity.scripts": "Scripts",
"activity.tags": "Tags",
"activity.aiAssistant": "AI Assistant",
"activity.import": "Import",
@@ -339,6 +340,7 @@
"gitSidebar.placeholder.commitMessage": "Commit message",
"editor.untitled": "Untitled",
"tabBar.style": "Style",
"tabBar.scripts": "Scripts",
"tabBar.loading": "Loading...",
"tabBar.unknown": "Unknown",
"tabBar.preview": "Preview",
@@ -415,6 +417,9 @@
"sidebar.nav.publishing": "Publishing",
"sidebar.nav.data": "Data",
"sidebar.nav.style": "Style",
"sidebar.nav.scripts": "Scripts",
"scripts.run": "Run Script",
"scripts.content": "Script Content",
"sidebar.tagCloud": "Tag Cloud",
"sidebar.createEdit": "Create & Edit",
"sidebar.mergeTags": "Merge Tags",
@@ -695,6 +700,11 @@
"sidebar.chat.yesterday": "Yesterday",
"sidebar.import.header": "IMPORTS",
"sidebar.import.newDefinition": "New Import Definition",
"sidebar.scripts.header": "SCRIPTS",
"sidebar.scripts.newScript": "New Script",
"sidebar.scripts.none": "No scripts yet",
"sidebar.scripts.createScript": "Create a script",
"sidebar.scripts.createFailed": "Failed to create script",
"sidebar.import.none": "No import definitions yet",
"sidebar.import.createDefinition": "Create an import definition",
"sidebar.import.deleteDefinition": "Delete import definition",

View File

@@ -9,6 +9,7 @@
"activity.posts": "Entradas",
"activity.pages": "Páginas",
"activity.media": "Medios",
"activity.scripts": "Scripts",
"activity.tags": "Etiquetas",
"activity.aiAssistant": "Asistente IA",
"activity.import": "Importar",
@@ -339,6 +340,7 @@
"gitSidebar.placeholder.commitMessage": "Mensaje de commit",
"editor.untitled": "Sin título",
"tabBar.style": "Estilo",
"tabBar.scripts": "Scripts",
"tabBar.loading": "Cargando...",
"tabBar.unknown": "Desconocido",
"tabBar.preview": "Vista previa",
@@ -415,6 +417,9 @@
"sidebar.nav.publishing": "Publicación",
"sidebar.nav.data": "Datos",
"sidebar.nav.style": "Estilo",
"sidebar.nav.scripts": "Scripts",
"scripts.run": "Ejecutar script",
"scripts.content": "Contenido del script",
"sidebar.tagCloud": "Nube de etiquetas",
"sidebar.createEdit": "Crear y editar",
"sidebar.mergeTags": "Combinar etiquetas",
@@ -695,6 +700,11 @@
"sidebar.chat.yesterday": "Ayer",
"sidebar.import.header": "Importación",
"sidebar.import.newDefinition": "Nueva definición",
"sidebar.scripts.header": "SCRIPTS",
"sidebar.scripts.newScript": "Nuevo script",
"sidebar.scripts.none": "Aún no hay scripts",
"sidebar.scripts.createScript": "Crear un script",
"sidebar.scripts.createFailed": "No se pudo crear el script",
"sidebar.import.none": "Sin definiciones de importación",
"sidebar.import.createDefinition": "Crear definición",
"sidebar.import.deleteDefinition": "Eliminar definición",

View File

@@ -9,6 +9,7 @@
"activity.posts": "Articles",
"activity.pages": "Pages du site",
"activity.media": "Médias",
"activity.scripts": "Scripts",
"activity.tags": "Étiquettes",
"activity.aiAssistant": "Assistant IA",
"activity.import": "Importation",
@@ -339,6 +340,7 @@
"gitSidebar.placeholder.commitMessage": "Message de commit",
"editor.untitled": "Sans titre",
"tabBar.style": "Apparence",
"tabBar.scripts": "Scripts",
"tabBar.loading": "Chargement...",
"tabBar.unknown": "Inconnu",
"tabBar.preview": "Aperçu",
@@ -415,6 +417,9 @@
"sidebar.nav.publishing": "Publication",
"sidebar.nav.data": "Données",
"sidebar.nav.style": "Style",
"sidebar.nav.scripts": "Scripts",
"scripts.run": "Exécuter le script",
"scripts.content": "Contenu du script",
"sidebar.tagCloud": "Nuage détiquettes",
"sidebar.createEdit": "Créer & modifier",
"sidebar.mergeTags": "Fusionner les étiquettes",
@@ -695,6 +700,11 @@
"sidebar.chat.yesterday": "Hier",
"sidebar.import.header": "Import",
"sidebar.import.newDefinition": "Nouvelle définition",
"sidebar.scripts.header": "SCRIPTS",
"sidebar.scripts.newScript": "Nouveau script",
"sidebar.scripts.none": "Aucun script",
"sidebar.scripts.createScript": "Créer un script",
"sidebar.scripts.createFailed": "Impossible de créer 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

@@ -9,6 +9,7 @@
"activity.posts": "Post",
"activity.pages": "Pagine",
"activity.media": "Contenuti media",
"activity.scripts": "Script",
"activity.tags": "Tag",
"activity.aiAssistant": "Assistente IA",
"activity.import": "Importa",
@@ -339,6 +340,7 @@
"gitSidebar.placeholder.commitMessage": "Messaggio di commit",
"editor.untitled": "Senza titolo",
"tabBar.style": "Stile",
"tabBar.scripts": "Script",
"tabBar.loading": "Caricamento...",
"tabBar.unknown": "Sconosciuto",
"tabBar.preview": "Anteprima",
@@ -415,6 +417,9 @@
"sidebar.nav.publishing": "Pubblicazione",
"sidebar.nav.data": "Dati",
"sidebar.nav.style": "Stile",
"sidebar.nav.scripts": "Script",
"scripts.run": "Esegui script",
"scripts.content": "Contenuto script",
"sidebar.tagCloud": "Nuvola tag",
"sidebar.createEdit": "Crea e modifica",
"sidebar.mergeTags": "Unisci tag",
@@ -695,6 +700,11 @@
"sidebar.chat.yesterday": "Ieri",
"sidebar.import.header": "Importazione",
"sidebar.import.newDefinition": "Nuova definizione",
"sidebar.scripts.header": "SCRIPTS",
"sidebar.scripts.newScript": "Nuovo script",
"sidebar.scripts.none": "Nessuno script",
"sidebar.scripts.createScript": "Crea uno script",
"sidebar.scripts.createFailed": "Impossibile creare lo script",
"sidebar.import.none": "Nessuna definizione di importazione",
"sidebar.import.createDefinition": "Crea definizione",
"sidebar.import.deleteDefinition": "Elimina definizione",

View File

@@ -1,7 +1,7 @@
import type { Tab } from '../store/appStore';
import type { SidebarView } from './sidebarViewRegistry';
export type ActivityId = 'posts' | 'pages' | 'media' | 'tags' | 'chat' | 'import' | 'git' | 'settings';
export type ActivityId = 'posts' | 'pages' | 'media' | 'scripts' | 'tags' | 'chat' | 'import' | 'git' | 'settings';
export interface ActivitySnapshot {
activeView: SidebarView;
@@ -43,6 +43,13 @@ const ACTIVITY_CONFIG: Record<ActivityId, ActivityConfig> = {
activeStrategy: 'sidebar-owner',
clickStrategy: 'sidebar-toggle',
},
scripts: {
id: 'scripts',
view: 'scripts',
labelKey: 'activity.scripts',
activeStrategy: 'sidebar-owner',
clickStrategy: 'sidebar-toggle',
},
tags: {
id: 'tags',
view: 'tags',

View File

@@ -14,7 +14,8 @@ export type EditorRoute =
| 'metadata-diff'
| 'git-diff'
| 'documentation'
| 'site-validation';
| 'site-validation'
| 'scripts';
export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'dashboard'>> = {
post: 'post',
@@ -29,6 +30,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'da
'git-diff': 'git-diff',
documentation: 'documentation',
'site-validation': 'site-validation',
scripts: 'scripts',
};
export interface EditorRouteResolution {

View File

@@ -2,6 +2,7 @@ export const SIDEBAR_VIEW_REGISTRY = [
'posts',
'pages',
'media',
'scripts',
'settings',
'tags',
'chat',

View File

@@ -4,6 +4,7 @@ export type SingletonToolTabKey =
| 'settings'
| 'tags'
| 'style'
| 'scripts'
| 'menu-editor'
| 'documentation'
| 'metadata-diff'
@@ -17,12 +18,14 @@ export interface CanonicalTabSpec {
export type EntityTabType = 'post' | 'media';
export type EntityTabOpenIntent = 'preview' | 'pin';
export type ScriptTabOpenIntent = 'preview' | 'pin';
export type GitDiffResourceOpenIntent = 'preview' | 'pin';
const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec> = {
settings: { type: 'settings', id: 'settings', isTransient: false },
tags: { type: 'tags', id: 'tags', isTransient: false },
style: { type: 'style', id: 'style', isTransient: false },
scripts: { type: 'scripts', id: 'scripts', isTransient: false },
'menu-editor': { type: 'menu-editor', id: 'menu-editor', isTransient: false },
documentation: { type: 'documentation', id: 'documentation', isTransient: false },
'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false },
@@ -91,6 +94,22 @@ export function openImportTab(
openTab(getImportTabSpec(definitionId));
}
export function getScriptTabSpec(scriptId: string, intent: ScriptTabOpenIntent): CanonicalTabSpec {
return {
type: 'scripts',
id: scriptId,
isTransient: intent === 'preview',
};
}
export function openScriptTab(
openTab: (tab: CanonicalTabSpec) => void,
scriptId: string,
intent: ScriptTabOpenIntent,
): void {
openTab(getScriptTabSpec(scriptId, intent));
}
export function getGitDiffFileTabId(filePath: string): string {
return `git-diff:${filePath}`;
}

View File

@@ -0,0 +1,7 @@
import { PythonRuntimeManager } from './PythonRuntimeManager';
const runtimeManager = new PythonRuntimeManager();
export function getPythonRuntimeManager(): PythonRuntimeManager {
return runtimeManager;
}

View File

@@ -13,7 +13,7 @@ import type {
const STORAGE_KEY = 'bds-app-state';
// Tab types
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation';
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation' | 'scripts';
export interface Tab {
type: TabType;
@@ -42,6 +42,13 @@ export type EditorMode = 'wysiwyg' | 'markdown' | 'preview';
export type GitDiffViewStyle = 'inline' | 'side-by-side';
export type PanelTab = 'tasks' | 'output' | 'post-links' | 'git-log';
export interface PanelOutputEntry {
id: string;
message: string;
createdAt: string;
kind: 'stdout' | 'result' | 'error';
}
export interface GitDiffPreferences {
wordWrap: boolean;
viewStyle: GitDiffViewStyle;
@@ -63,6 +70,7 @@ interface AppState {
sidebarVisible: boolean;
panelVisible: boolean;
panelActiveTab: PanelTab;
panelOutputEntries: PanelOutputEntry[];
selectedPostId: string | null;
selectedMediaId: string | null;
preferredEditorMode: EditorMode;
@@ -112,6 +120,8 @@ interface AppState {
toggleSidebar: () => void;
togglePanel: () => void;
setPanelActiveTab: (tab: PanelTab) => void;
appendPanelOutputEntry: (entry: PanelOutputEntry) => void;
clearPanelOutputEntries: () => void;
setSelectedPost: (id: string | null) => void;
setSelectedMedia: (id: string | null) => void;
setPreferredEditorMode: (mode: EditorMode) => void;
@@ -166,6 +176,7 @@ export const useAppStore = create<AppState>()(
sidebarVisible: true,
panelVisible: false,
panelActiveTab: 'tasks',
panelOutputEntries: [],
selectedPostId: null,
selectedMediaId: null,
preferredEditorMode: 'wysiwyg',
@@ -290,6 +301,10 @@ export const useAppStore = create<AppState>()(
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
setPanelActiveTab: (panelActiveTab) => set({ panelActiveTab }),
appendPanelOutputEntry: (entry) => set((state) => ({
panelOutputEntries: [...state.panelOutputEntries, entry],
})),
clearPanelOutputEntries: () => set({ panelOutputEntries: [] }),
setSelectedPost: (id) => set({ selectedPostId: id }),
setSelectedMedia: (id) => set({ selectedMediaId: id }),
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),

View File

@@ -6,6 +6,7 @@ export {
type TaskProgress,
type EditorMode,
type ErrorDetails,
type PanelOutputEntry,
type Tab,
type TabType,
type TabState

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as fs from 'fs/promises';
import { ScriptEngine } from '../../src/main/engine/ScriptEngine';
const mockScripts = new Map<string, any>();
const mockFiles = new Map<string, string>();
function createSelectChain() {
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockScripts.values()))),
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
};
}
function createDrizzleMock() {
return {
select: vi.fn(() => createSelectChain()),
insert: vi.fn(() => ({
values: vi.fn((data: any) => {
mockScripts.set(data.id, data);
return Promise.resolve();
}),
})),
update: vi.fn(() => ({
set: vi.fn((updates: any) => ({
where: vi.fn(async () => {
for (const [scriptId, existing] of mockScripts.entries()) {
mockScripts.set(scriptId, { ...existing, ...updates });
}
}),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(async () => {
mockScripts.clear();
return Promise.resolve();
}),
})),
};
}
const mockLocalDb = createDrizzleMock();
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
})),
}));
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-script-id'),
}));
vi.mock('fs/promises', () => ({
readFile: vi.fn(async (filePath: string) => {
const value = (globalThis as any).__mockScriptFiles.get(filePath);
if (typeof value !== 'string') {
const error = new Error('ENOENT');
(error as any).code = 'ENOENT';
throw error;
}
return value;
}),
writeFile: vi.fn(async (filePath: string, content: string) => {
(globalThis as any).__mockScriptFiles.set(filePath, content);
}),
unlink: vi.fn(async (filePath: string) => {
(globalThis as any).__mockScriptFiles.delete(filePath);
}),
rename: vi.fn(async (fromPath: string, toPath: string) => {
const files = (globalThis as any).__mockScriptFiles;
const content = files.get(fromPath);
files.delete(fromPath);
files.set(toPath, content);
}),
mkdir: vi.fn(async () => {}),
}));
describe('ScriptEngine', () => {
let scriptEngine: ScriptEngine;
beforeEach(() => {
vi.clearAllMocks();
mockScripts.clear();
mockFiles.clear();
(globalThis as any).__mockScriptFiles = mockFiles;
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
scriptEngine = new ScriptEngine();
scriptEngine.setProjectContext('default', '/mock/userData/projects/default');
});
it('creates script metadata and source file', async () => {
const created = await scriptEngine.createScript({
title: 'Render Hero',
kind: 'macro',
content: 'def render(context):\n return {"html": "<h1>Hi</h1>"}',
});
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');
});
it('updates script metadata and file content', async () => {
const created = await scriptEngine.createScript({
title: 'Render Hero',
kind: 'macro',
content: 'def render(context):\n return {"html": "<h1>Hi</h1>"}',
});
const updated = await scriptEngine.updateScript(created.id, {
title: 'Render Hero Banner',
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');
});
it('deletes script metadata and source file', async () => {
const created = await scriptEngine.createScript({
title: 'Delete Me',
kind: 'utility',
content: 'print("bye")',
});
const deleted = await scriptEngine.deleteScript(created.id);
expect(deleted).toBe(true);
expect(mockScripts.has(created.id)).toBe(false);
expect(mockFiles.has('/mock/userData/projects/default/scripts/delete-me.py')).toBe(false);
});
});

View File

@@ -158,6 +158,16 @@ const mockPostMediaEngine = {
rebuildFromSidecars: vi.fn(),
};
const mockScriptEngine = {
on: vi.fn(),
setProjectContext: vi.fn(),
createScript: vi.fn(),
updateScript: vi.fn(),
deleteScript: vi.fn(),
getScript: vi.fn(),
getAllScripts: vi.fn(),
};
const mockGitEngine = {
checkAvailability: vi.fn(),
getHeadCommit: vi.fn(),
@@ -263,6 +273,10 @@ vi.mock('../../src/main/engine/PostMediaEngine', () => ({
getPostMediaEngine: vi.fn(() => mockPostMediaEngine),
}));
vi.mock('../../src/main/engine/ScriptEngine', () => ({
getScriptEngine: vi.fn(() => mockScriptEngine),
}));
vi.mock('../../src/main/engine/GitEngine', () => ({
getGitEngine: vi.fn(() => mockGitEngine),
}));
@@ -2593,6 +2607,113 @@ describe('IPC Handlers', () => {
});
});
// ============ Script Handlers ============
describe('Script Handlers', () => {
describe('scripts:create', () => {
it('should call ScriptEngine.createScript with payload', async () => {
const payload = {
title: 'Render Hero',
kind: 'macro',
content: 'def render(context):\n return {"html":"<h1>Hi</h1>"}',
};
const expected = {
id: 'script-1',
projectId: 'default',
...payload,
slug: 'render-hero',
entrypoint: 'render',
enabled: true,
version: 1,
filePath: '/mock/userData/projects/default/scripts/render-hero.py',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockScriptEngine.createScript.mockResolvedValue(expected);
const result = await invokeHandler('scripts:create', payload);
expect(mockScriptEngine.createScript).toHaveBeenCalledWith(payload);
expect(result).toEqual(expected);
});
});
describe('scripts:update', () => {
it('should call ScriptEngine.updateScript with id and updates', async () => {
const updates = { title: 'Updated Script', content: 'print("updated")' };
const expected = {
id: 'script-1',
projectId: 'default',
slug: 'updated-script',
title: 'Updated Script',
kind: 'utility',
entrypoint: 'render',
enabled: true,
version: 2,
filePath: '/mock/userData/projects/default/scripts/updated-script.py',
content: 'print("updated")',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockScriptEngine.updateScript.mockResolvedValue(expected);
const result = await invokeHandler('scripts:update', 'script-1', updates);
expect(mockScriptEngine.updateScript).toHaveBeenCalledWith('script-1', updates);
expect(result).toEqual(expected);
});
});
describe('scripts:delete', () => {
it('should call ScriptEngine.deleteScript with id', async () => {
mockScriptEngine.deleteScript.mockResolvedValue(true);
const result = await invokeHandler('scripts:delete', 'script-1');
expect(mockScriptEngine.deleteScript).toHaveBeenCalledWith('script-1');
expect(result).toBe(true);
});
});
describe('scripts:get', () => {
it('should call ScriptEngine.getScript with id', async () => {
const expected = {
id: 'script-1',
projectId: 'default',
slug: 'render-hero',
title: 'Render Hero',
kind: 'macro',
entrypoint: 'render',
enabled: true,
version: 1,
filePath: '/mock/userData/projects/default/scripts/render-hero.py',
content: 'def render(context):\n return {"html":"<h1>Hi</h1>"}',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockScriptEngine.getScript.mockResolvedValue(expected);
const result = await invokeHandler('scripts:get', 'script-1');
expect(mockScriptEngine.getScript).toHaveBeenCalledWith('script-1');
expect(result).toEqual(expected);
});
});
describe('scripts:getAll', () => {
it('should call ScriptEngine.getAllScripts', async () => {
const expected = [{ id: 'script-1' }, { id: 'script-2' }];
mockScriptEngine.getAllScripts.mockResolvedValue(expected);
const result = await invokeHandler('scripts:getAll');
expect(mockScriptEngine.getAllScripts).toHaveBeenCalled();
expect(result).toEqual(expected);
});
});
});
// ============ Error Handling ============
describe('Error Handling', () => {
it('should silently handle "Database is closing" errors', async () => {

View File

@@ -235,6 +235,24 @@ describe('Panel', () => {
expect(screen.getByRole('tab', { name: 'Output' })).toHaveAttribute('aria-selected', 'true');
});
it('renders output entries when output tab is active', () => {
useAppStore.setState({
panelActiveTab: 'output',
panelOutputEntries: [
{
id: 'output-1',
message: 'hello from script',
createdAt: '2026-02-22T00:00:00.000Z',
kind: 'stdout',
},
],
});
render(<Panel />);
expect(screen.getByText('hello from script')).toBeInTheDocument();
});
it('renders grouped tasks as expandable parent rows with child task names in tasks tab', async () => {
useAppStore.setState({
tasks: [

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('ScriptsView styles', () => {
const cssPath = path.resolve(
__dirname,
'../../../src/renderer/components/ScriptsView/ScriptsView.css'
);
it('uses full editor area layout for the scripts container', () => {
const css = fs.readFileSync(cssPath, 'utf8');
expect(css).toMatch(/\.scripts-view\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
});
it('keeps editor and textarea stretched to fill available space', () => {
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);
});
});

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ScriptsView } from '../../../src/renderer/components/ScriptsView/ScriptsView';
import { useAppStore } from '../../../src/renderer/store';
const executeMock = vi.fn();
vi.mock('../../../src/renderer/python/runtimeManagerInstance', () => ({
getPythonRuntimeManager: () => ({
execute: executeMock,
}),
}));
describe('ScriptsView', () => {
beforeEach(() => {
vi.clearAllMocks();
executeMock.mockResolvedValue({ result: '2', stdout: 'hello\n' });
(window as any).electronAPI = {
...(window as any).electronAPI,
scripts: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn().mockResolvedValue({
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',
}),
getAll: vi.fn(),
},
};
useAppStore.setState({
panelVisible: false,
panelActiveTab: 'tasks',
panelOutputEntries: [],
});
});
it('loads scripts and allows editing content', async () => {
render(<ScriptsView scriptId="script-1" />);
const textarea = screen.getByLabelText('Script Content') as HTMLTextAreaElement;
await vi.waitFor(() => {
expect(textarea.value).toContain('print("hello")');
});
fireEvent.change(textarea, { target: { value: 'print("updated")' } });
expect(textarea.value).toContain('updated');
});
it('runs selected script and writes output into panel output log', async () => {
render(<ScriptsView scriptId="script-1" />);
await screen.findByLabelText('Script Content');
fireEvent.click(screen.getByRole('button', { name: 'Run Script' }));
await vi.waitFor(() => {
expect(executeMock).toHaveBeenCalledWith('print("hello")');
});
const state = useAppStore.getState();
expect(state.panelVisible).toBe(true);
expect(state.panelActiveTab).toBe('output');
expect(state.panelOutputEntries.length).toBeGreaterThan(0);
expect(state.panelOutputEntries[state.panelOutputEntries.length - 1].message).toContain('hello');
});
});

View File

@@ -0,0 +1,147 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Sidebar } from '../../../src/renderer/components/Sidebar/Sidebar';
import { useAppStore } from '../../../src/renderer/store';
describe('Sidebar scripts list behavior', () => {
beforeEach(() => {
vi.clearAllMocks();
(window as any).electronAPI = {
...(window as any).electronAPI,
scripts: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
getAll: vi.fn().mockResolvedValue([
{
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',
},
]),
},
};
useAppStore.setState({
activeView: 'scripts',
sidebarVisible: true,
tabs: [],
activeTabId: null,
});
});
it('opens a transient script tab on single click', async () => {
render(<Sidebar />);
const scriptRow = await screen.findByRole('button', { name: 'Hello Script' });
fireEvent.click(scriptRow);
expect(useAppStore.getState().tabs).toEqual([
{
type: 'scripts',
id: 'script-1',
isTransient: true,
},
]);
expect(useAppStore.getState().activeTabId).toBe('script-1');
});
it('renders scripts section title and create button', async () => {
render(<Sidebar />);
expect(screen.getByText('SCRIPTS')).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'New Script' })).toBeInTheDocument();
});
it('shows loading state while scripts are being fetched', () => {
(window as any).electronAPI.scripts.getAll = vi.fn().mockImplementation(
() => new Promise(() => {}),
);
render(<Sidebar />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('shows empty state with create action when no scripts exist', async () => {
(window as any).electronAPI.scripts.getAll = vi.fn().mockResolvedValue([]);
render(<Sidebar />);
expect(await screen.findByText('No scripts yet')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Create a script' })).toBeInTheDocument();
});
it('creates a new script from the create button and opens it pinned', async () => {
const createMock = vi.fn().mockResolvedValue({
id: 'script-new',
projectId: 'default',
slug: 'new-script',
title: 'New Script',
kind: 'utility',
entrypoint: 'render',
enabled: true,
version: 1,
filePath: '/tmp/new-script.py',
content: 'print("new script")',
createdAt: '2026-02-22T00:00:00.000Z',
updatedAt: '2026-02-22T00:00:00.000Z',
});
(window as any).electronAPI.scripts.create = createMock;
render(<Sidebar />);
fireEvent.click(await screen.findByRole('button', { name: 'New Script' }));
await vi.waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
title: 'New Script',
kind: 'utility',
entrypoint: 'render',
enabled: true,
}),
);
});
await vi.waitFor(() => {
expect(useAppStore.getState().tabs).toEqual([
{
type: 'scripts',
id: 'script-new',
isTransient: false,
},
]);
expect(useAppStore.getState().activeTabId).toBe('script-new');
});
});
it('opens a pinned script tab on double click', async () => {
render(<Sidebar />);
const scriptRow = await screen.findByRole('button', { name: 'Hello Script' });
fireEvent.doubleClick(scriptRow);
expect(useAppStore.getState().tabs).toEqual([
{
type: 'scripts',
id: 'script-1',
isTransient: false,
},
]);
expect(useAppStore.getState().activeTabId).toBe('script-1');
});
});

View File

@@ -87,7 +87,20 @@ describe('activityBehavior', () => {
});
it('supports all expected activity ids', () => {
const ids: ActivityId[] = ['posts', 'pages', 'media', 'tags', 'chat', 'import', 'git', 'settings'];
expect(ids).toHaveLength(8);
const ids: ActivityId[] = ['posts', 'pages', 'media', 'scripts', 'tags', 'chat', 'import', 'git', 'settings'];
expect(ids).toHaveLength(9);
});
it('returns posts-style sidebar actions for scripts', () => {
const hiddenSidebarSnapshot = createSnapshot({ activeView: 'posts', sidebarVisible: false });
expect(getActivityClickActions(hiddenSidebarSnapshot, 'scripts')).toEqual([
{ type: 'setActiveView', view: 'scripts' },
{ type: 'toggleSidebar' },
]);
const visibleSidebarSnapshot = createSnapshot({ activeView: 'posts', sidebarVisible: true });
expect(getActivityClickActions(visibleSidebarSnapshot, 'scripts')).toEqual([
{ type: 'setActiveView', view: 'scripts' },
]);
});
});

View File

@@ -20,6 +20,7 @@ describe('editorRouting', () => {
'git-diff': 'git-diff',
documentation: 'documentation',
'site-validation': 'site-validation',
scripts: 'scripts',
});
});

View File

@@ -11,6 +11,7 @@ describe('sidebarViewRegistry', () => {
'posts',
'pages',
'media',
'scripts',
'settings',
'tags',
'chat',

View File

@@ -5,6 +5,7 @@ import {
getGitDiffCommitTabSpec,
getGitDiffFileTabSpec,
getImportTabSpec,
getScriptTabSpec,
parseGitDiffTabId,
openChatTab,
getSingletonToolTabSpec,
@@ -12,6 +13,7 @@ import {
openGitDiffCommitTab,
openGitDiffFileTab,
openImportTab,
openScriptTab,
openSingletonToolTab,
} from '../../../src/renderer/navigation/tabPolicy';
@@ -20,6 +22,7 @@ describe('tabPolicy', () => {
expect(getSingletonToolTabSpec('settings')).toEqual({ type: 'settings', id: 'settings', isTransient: false });
expect(getSingletonToolTabSpec('tags')).toEqual({ type: 'tags', id: 'tags', isTransient: false });
expect(getSingletonToolTabSpec('style')).toEqual({ type: 'style', id: 'style', isTransient: false });
expect(getSingletonToolTabSpec('scripts')).toEqual({ type: 'scripts', id: 'scripts', isTransient: false });
expect(getSingletonToolTabSpec('menu-editor')).toEqual({ type: 'menu-editor', id: 'menu-editor', isTransient: false });
expect(getSingletonToolTabSpec('documentation')).toEqual({ type: 'documentation', id: 'documentation', isTransient: false });
expect(getSingletonToolTabSpec('metadata-diff')).toEqual({ type: 'metadata-diff', id: 'metadata-diff', isTransient: false });
@@ -93,6 +96,35 @@ describe('tabPolicy', () => {
]);
});
it('provides canonical script tab spec for preview and pin intents', () => {
expect(getScriptTabSpec('script-1', 'preview')).toEqual({
type: 'scripts',
id: 'script-1',
isTransient: true,
});
expect(getScriptTabSpec('script-1', 'pin')).toEqual({
type: 'scripts',
id: 'script-1',
isTransient: false,
});
});
it('opens script tabs from shared policy', () => {
const opened: Array<{ type: string; id: string; isTransient: boolean }> = [];
const openTab = (tab: { type: string; id: string; isTransient: boolean }) => {
opened.push(tab);
};
openScriptTab(openTab, 'script-preview', 'preview');
openScriptTab(openTab, 'script-pin', 'pin');
expect(opened).toEqual([
{ type: 'scripts', id: 'script-preview', isTransient: true },
{ type: 'scripts', id: 'script-pin', isTransient: false },
]);
});
it('builds and parses git-diff file and commit tab specs', () => {
expect(getGitDiffFileTabSpec('posts/first.md', 'preview')).toEqual({
type: 'git-diff',

View File

@@ -22,4 +22,10 @@ describe('vite renderer chunking', () => {
const resolved = typeof viteConfig === 'function' ? viteConfig({ command: 'build', mode: 'production', isSsrBuild: false, isPreview: false }) : viteConfig;
expect(resolved.build?.chunkSizeWarningLimit).toBe(8000);
});
it('excludes pyodide from optimizeDeps pre-bundling', () => {
const resolved = typeof viteConfig === 'function' ? viteConfig({ command: 'serve', mode: 'development', isSsrBuild: false, isPreview: false }) : viteConfig;
const excluded = resolved.optimizeDeps?.exclude ?? [];
expect(excluded).toContain('pyodide');
});
});

View File

@@ -41,4 +41,10 @@ export default defineConfig({
server: {
port: 5173,
},
optimizeDeps: {
exclude: ['pyodide'],
},
worker: {
format: 'es',
},
});