From 1be24611772e3e172b3fe21d3af42142c56aa19c Mon Sep 17 00:00:00 2001 From: lyx-tec Date: Sun, 19 Jul 2026 17:46:54 +0800 Subject: [PATCH] feat: per-workspace script management system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a script management system that lets users store, edit, and execute shell scripts within a workspace context. Key features: - Scripts stored in workspace.meta['scripts:list'] (typed WorkspaceScript[]) - Script execution via heredoc → mktemp → source → rm pipeline, supporting bash/zsh/sh/dash (POSIX '.' dot command), with block on fish, cmd, and PowerShell - Script Picker modal (Ctrl+Shift+e or right-click 'Run Script…') with search, keyboard navigation, inline delete, and '+ Add Script' entry - Script Management modal with inline CRUD (edit-in-place, add-at-bottom) - Workspace editor preview card showing first script with live WOS atom - blockId explicitly threaded through right-click → picker → runScript - Go-side MetaTSType registration for 'scripts:list' with auto-generated TypeScript types --- frontend/app/modals/modalregistry.tsx | 4 + .../app/modals/workspace-scripts-modal.scss | 278 ++++++++++++++++++ .../app/modals/workspace-scripts-modal.tsx | 224 ++++++++++++++ .../app/modals/workspace-scripts-picker.scss | 164 +++++++++++ .../app/modals/workspace-scripts-picker.tsx | 173 +++++++++++ frontend/app/store/keymodel.ts | 4 + frontend/app/tab/workspaceeditor.scss | 15 + frontend/app/tab/workspaceeditor.tsx | 24 ++ frontend/app/tab/workspaceswitcherdetail.tsx | 1 + frontend/app/view/term/term-model.ts | 12 +- frontend/app/view/term/term.tsx | 4 +- frontend/app/workspace/workspace-scripts.ts | 120 ++++++++ frontend/types/gotypes.d.ts | 10 + pkg/waveobj/metaconsts.go | 2 + pkg/waveobj/wtype.go | 10 + pkg/waveobj/wtypemeta.go | 5 +- 16 files changed, 1045 insertions(+), 5 deletions(-) create mode 100644 frontend/app/modals/workspace-scripts-modal.scss create mode 100644 frontend/app/modals/workspace-scripts-modal.tsx create mode 100644 frontend/app/modals/workspace-scripts-picker.scss create mode 100644 frontend/app/modals/workspace-scripts-picker.tsx create mode 100644 frontend/app/workspace/workspace-scripts.ts diff --git a/frontend/app/modals/modalregistry.tsx b/frontend/app/modals/modalregistry.tsx index ec583b4cbe..cf1330af09 100644 --- a/frontend/app/modals/modalregistry.tsx +++ b/frontend/app/modals/modalregistry.tsx @@ -10,6 +10,8 @@ import { SetSecretDialog } from "@/builder/tabs/builder-secrettab"; import { AboutModal } from "./about"; import { ConfirmModal } from "./confirmmodal"; import { UserInputModal } from "./userinputmodal"; +import { WorkspaceScriptsModal } from "./workspace-scripts-modal"; +import { WorkspaceScriptsPicker } from "./workspace-scripts-picker"; const modalRegistry: { [key: string]: React.ComponentType } = { [NewInstallOnboardingModal.displayName || "NewInstallOnboardingModal"]: NewInstallOnboardingModal, @@ -23,6 +25,8 @@ const modalRegistry: { [key: string]: React.ComponentType } = { [RenameFileModal.displayName || "RenameFileModal"]: RenameFileModal, [DeleteFileModal.displayName || "DeleteFileModal"]: DeleteFileModal, [SetSecretDialog.displayName || "SetSecretDialog"]: SetSecretDialog, + [WorkspaceScriptsPicker.displayName || "WorkspaceScriptsPicker"]: WorkspaceScriptsPicker, + [WorkspaceScriptsModal.displayName || "WorkspaceScriptsModal"]: WorkspaceScriptsModal, }; export const getModalComponent = (key: string): React.ComponentType | undefined => { diff --git a/frontend/app/modals/workspace-scripts-modal.scss b/frontend/app/modals/workspace-scripts-modal.scss new file mode 100644 index 0000000000..47af847f9a --- /dev/null +++ b/frontend/app/modals/workspace-scripts-modal.scss @@ -0,0 +1,278 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.workspace-scripts-modal { + width: 520px; + max-height: 70vh; + display: flex; + flex-direction: column; + padding: 16px 0 12px; + gap: 0; + + .scripts-modal-root { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + width: 100%; + outline: none; + } + + .scripts-modal-header { + font-size: 14px; + font-weight: 600; + color: var(--main-text-color); + padding: 0 20px 12px; + user-select: none; + display: flex; + align-items: center; + gap: 8px; + + &::before { + content: "\f085"; + font-family: "Font Awesome 6 Sharp", "Font Awesome 6 Free", "Font Awesome 5 Free", sans-serif; + font-weight: 900; + font-size: 12px; + color: var(--accent-color); + } + } + + .scripts-modal-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 20px; + } + + .modal-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + color: var(--secondary-text-color); + } + + .scripts-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 44px 20px; + gap: 10px; + + .scripts-empty-icon { + font-size: 32px; + color: var(--secondary-text-color); + opacity: 0.25; + } + + .scripts-empty-text { + font-size: 14px; + font-weight: 500; + color: var(--main-text-color); + margin-top: 2px; + } + + .scripts-empty-subtext { + font-size: 12px; + color: var(--secondary-text-color); + text-align: center; + line-height: 1.5; + } + } + + .scripts-list { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 0; + } + + .script-item { + border: 1px solid var(--modal-border-color); + border-radius: 6px; + padding: 10px 12px; + background: var(--modal-bg-color); + transition: border-color 0.15s, box-shadow 0.15s; + position: relative; + + &:hover { + border-color: rgb(from var(--accent-color) r g b / 0.3); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + } + + .script-item-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .script-item-name { + font-size: 13px; + font-weight: 600; + color: var(--main-text-color); + } + + .script-item-actions { + display: flex; + gap: 2px; + opacity: 0; + transition: opacity 0.12s; + } + + &:hover .script-item-actions { + opacity: 1; + } + + .script-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--secondary-text-color); + cursor: pointer; + font-size: 12px; + transition: background-color 0.12s, color 0.12s; + + &:hover { + background-color: var(--highlight-bg-color); + color: var(--main-text-color); + } + + &.delete:hover { + color: var(--error-color); + background-color: rgb(from var(--error-color) r g b / 0.12); + } + } + + .script-item-desc { + font-size: 11px; + color: var(--secondary-text-color); + opacity: 0.7; + margin-top: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + } + + .script-item-command { + font-size: 11px; + color: var(--secondary-text-color); + font-family: var(--termfontfamily); + margin-top: 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + } + } + + // Left accent bar signals "this is the editing area" without creating a + // double-frame effect when the inner input/textarea is focused. + .script-edit-form { + border: 1px solid var(--modal-border-color); + border-left: 3px solid var(--accent-color); + border-radius: 6px; + padding: 12px 12px 12px 10px; + display: flex; + flex-direction: column; + gap: 10px; + } + + .script-form-field { + display: flex; + flex-direction: column; + gap: 4px; + + label { + font-size: 12px; + font-weight: 500; + color: var(--main-text-color); + user-select: none; + } + + .input { + font-size: 13px; + padding: 7px 10px; + background: var(--form-element-bg-color); + border-color: var(--form-element-border-color); + + &:focus-within { + border-color: var(--accent-color); + } + } + } + + .form-field-grow { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + + textarea { + flex: 1; + min-height: 80px; + } + } + + textarea { + width: 100%; + font-size: 12px; + padding: 7px 10px; + border: 1px solid var(--form-element-border-color); + border-radius: 4px; + background: var(--form-element-bg-color); + color: var(--main-text-color); + resize: vertical; + line-height: 1.5; + transition: border-color 0.15s; + + &:focus { + outline: none; + border-color: var(--accent-color); + } + + &::placeholder { + color: var(--secondary-text-color); + opacity: 0.5; + } + } + + .script-command-textarea { + font-family: var(--termfontfamily); + min-height: 72px; + } + + .script-desc-textarea { + font-family: var(--base-font); + min-height: 40px; + } + + .script-form-error { + font-size: 12px; + color: var(--error-color); + padding: 6px 8px; + background: rgb(from var(--error-color) r g b / 0.08); + border-radius: 4px; + } + + .script-form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 2px; + } + + .scripts-modal-footer { + display: flex; + justify-content: center; + padding: 12px 20px 0; + border-top: 1px solid var(--modal-border-color); + margin: 0 20px; + } +} diff --git a/frontend/app/modals/workspace-scripts-modal.tsx b/frontend/app/modals/workspace-scripts-modal.tsx new file mode 100644 index 0000000000..18689bab6c --- /dev/null +++ b/frontend/app/modals/workspace-scripts-modal.tsx @@ -0,0 +1,224 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Button } from "@/app/element/button"; +import { Input } from "@/app/element/input"; +import { modalsModel } from "@/app/store/modalmodel"; +import { WOS } from "@/app/store/global"; +import { + getScriptsFromWorkspace, + saveScriptsToWorkspace, +} from "@/app/workspace/workspace-scripts"; +import { Modal } from "./modal"; +import "./workspace-scripts-modal.scss"; +import { memo, useCallback, useState } from "react"; + +interface WorkspaceScriptsModalProps { + workspaceId: string; +} + +const WorkspaceScriptsModalComponent = ({ workspaceId }: WorkspaceScriptsModalProps) => { + const [workspace] = WOS.useWaveObjectValue(WOS.makeORef("workspace", workspaceId)); + const [editingId, setEditingId] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + const [draftName, setDraftName] = useState(""); + const [draftCommand, setDraftCommand] = useState(""); + const [draftDesc, setDraftDesc] = useState(""); + const [errorMsg, setErrorMsg] = useState(""); + + const scripts = getScriptsFromWorkspace(workspace); + const isEditing = editingId != null || showAddForm; + + const resetForm = useCallback(() => { + setDraftName(""); + setDraftCommand(""); + setDraftDesc(""); + setEditingId(null); + setShowAddForm(false); + setErrorMsg(""); + }, []); + + const startEdit = useCallback((script: WorkspaceScript) => { + setEditingId(script.id); + setDraftName(script.name); + setDraftCommand(script.command); + setDraftDesc(script.desc ?? ""); + setErrorMsg(""); + }, []); + + const startAdd = useCallback(() => { + resetForm(); + setShowAddForm(true); + }, [resetForm]); + + const validateForm = useCallback((): string | null => { + const name = draftName.trim(); + if (!name) return "Script name is required"; + const command = draftCommand.trim(); + if (!command) return "Command is required"; + const isDup = scripts.some((s) => s.name === name && s.id !== editingId); + if (isDup) return `A script named "${name}" already exists`; + return null; + }, [draftName, draftCommand, scripts, editingId]); + + const handleSave = useCallback(() => { + const err = validateForm(); + if (err) { + setErrorMsg(err); + return; + } + setErrorMsg(""); + if (editingId) { + const updated = scripts.map((s) => + s.id === editingId + ? { ...s, name: draftName.trim(), command: draftCommand.trim(), desc: draftDesc.trim() || undefined } + : s + ); + saveScriptsToWorkspace(workspaceId, updated); + } else { + const newScript: WorkspaceScript = { + id: crypto.randomUUID(), + name: draftName.trim(), + command: draftCommand.trim(), + desc: draftDesc.trim() || undefined, + }; + saveScriptsToWorkspace(workspaceId, [...scripts, newScript]); + } + resetForm(); + }, [editingId, draftName, draftCommand, draftDesc, scripts, workspaceId, validateForm, resetForm]); + + const handleDelete = useCallback( + (script: WorkspaceScript) => { + modalsModel.pushModal("ConfirmModal", { + message: `Delete script "${script.name}"?\nThis action cannot be undone.`, + onOk: () => { + const updated = scripts.filter((s) => s.id !== script.id); + saveScriptsToWorkspace(workspaceId, updated); + }, + }); + }, + [scripts, workspaceId] + ); + + // Handle Escape locally so the global modal-pop handler doesn't also fire. + // While editing, Escape only cancels the form; otherwise it closes the modal. + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== "Escape") return; + e.preventDefault(); + e.stopPropagation(); + if (isEditing) { + resetForm(); + } else { + modalsModel.popModal(); + } + }, + [isEditing, resetForm] + ); + + if (!workspace) { + return ( + modalsModel.popModal()}> +
Loading...
+
+ ); + } + + const renderForm = (submitLabel: string, growCommand?: boolean) => ( +
+
+ + +
+
+ +