From dfbf7329d18a09cded7e9bc19cf3014551132765 Mon Sep 17 00:00:00 2001 From: xlx1212 Date: Wed, 5 Aug 2026 04:21:47 +0800 Subject: [PATCH] feat: add Run Script command for script files in editor Adds a RunScriptCommand that detects the script language and runs it in a new terminal session. Supports Python, JavaScript, TypeScript, Shell, PowerShell, Batch, Ruby, Go, Rust, PHP, Lua, and Perl. Also adds a Run Script entry to the tab context menu, shown only when the file has a recognized interpreter. Closes #1566 --- .../panels/content-canvas/tab-bar/Tab.tsx | 14 ++ src/web-ui/src/locales/en-US/common.json | 4 + src/web-ui/src/locales/en-US/errors.json | 4 +- src/web-ui/src/locales/zh-CN/common.json | 4 + src/web-ui/src/locales/zh-CN/errors.json | 4 +- src/web-ui/src/locales/zh-TW/common.json | 4 + src/web-ui/src/locales/zh-TW/errors.json | 4 +- .../commands/builtin/file/RunScriptCommand.ts | 127 ++++++++++++++++++ .../commands/builtin/file/index.ts | 5 +- 9 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 src/web-ui/src/shared/context-menu-system/commands/builtin/file/RunScriptCommand.ts diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx index 990e18761e..3e0ac14fb7 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx @@ -15,6 +15,7 @@ import { workspaceManager } from '@/infrastructure/services/business/workspaceMa import { isRemoteWorkspace } from '@/shared/types'; import { hasNonFileUriScheme } from '@/shared/utils/pathUtils'; import { isHtmlFilePath } from '@/shared/utils/htmlFilePreview'; +import { getInterpreterForFile } from '@/shared/context-menu-system/commands/builtin/file/RunScriptCommand'; import type { CanvasTab, EditorGroupId, TabState } from '../types'; import './Tab.scss'; export interface TabProps { @@ -227,6 +228,19 @@ export const Tab: React.FC = ({ }, ); + // Run Script — shown when the file has a known interpreter + const interpreter = canUseLocalFileActions && filePath + ? getInterpreterForFile(filePath) + : undefined; + if (interpreter) { + items.push({ + id: 'tab-run-script', + label: t('common:file.runScript'), + icon: 'Play', + onClick: () => runCommand('file.run-script', context), + }); + } + if (isHtmlFilePath(filePath)) { items.push({ id: 'tab-open-html-in-browser', diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 6e4f7d8372..0e005121b8 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -1039,6 +1039,10 @@ "openWith": "Open With", "openInBrowser": "Open in Browser", "reveal": "Reveal in Explorer", + "runScript": "Run Script", + "runScriptDescription": "Run this script file in a terminal", + "runScriptStarting": "Running {{file}}...", + "runScriptSessionName": "Run: {{file}}", "copyPath": "Copy Path", "copyRelativePath": "Copy Relative Path", "download": "Download", diff --git a/src/web-ui/src/locales/en-US/errors.json b/src/web-ui/src/locales/en-US/errors.json index 2cf15c2e2b..641b36ff82 100644 --- a/src/web-ui/src/locales/en-US/errors.json +++ b/src/web-ui/src/locales/en-US/errors.json @@ -26,7 +26,9 @@ "createFolderFailed": "Failed to create folder", "renameFailed": "Rename failed", "openInBrowserFailed": "Failed to open file in browser", - "revealFailed": "Failed to open file manager" + "revealFailed": "Failed to open file manager", + "runScriptFailed": "Failed to run script", + "runScriptUnsupported": "This file type is not supported for running scripts" }, "workspace": { "notFound": "Workspace not found", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 8bca95499e..ca278cbcdc 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -1039,6 +1039,10 @@ "openWith": "打开方式", "openInBrowser": "在浏览器中打开", "reveal": "在资源管理器中显示", + "runScript": "运行脚本", + "runScriptDescription": "在终端中运行此脚本文件", + "runScriptStarting": "正在运行 {{file}}...", + "runScriptSessionName": "运行: {{file}}", "copyPath": "复制路径", "copyRelativePath": "复制相对路径", "download": "下载", diff --git a/src/web-ui/src/locales/zh-CN/errors.json b/src/web-ui/src/locales/zh-CN/errors.json index 92d3599ca8..6d6cd6835d 100644 --- a/src/web-ui/src/locales/zh-CN/errors.json +++ b/src/web-ui/src/locales/zh-CN/errors.json @@ -26,7 +26,9 @@ "createFolderFailed": "Failed to create folder", "renameFailed": "Rename failed", "openInBrowserFailed": "在浏览器中打开文件失败", - "revealFailed": "Failed to open file manager" + "revealFailed": "Failed to open file manager", + "runScriptFailed": "运行脚本失败", + "runScriptUnsupported": "此文件类型不支持运行脚本" }, "workspace": { "notFound": "工作区未找到", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 6548345cba..e3ff34a0e4 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -1039,6 +1039,10 @@ "openWith": "開啟方式", "openInBrowser": "在瀏覽器中開啟", "reveal": "在資源管理器中顯示", + "runScript": "執行腳本", + "runScriptDescription": "在終端中執行此腳本檔案", + "runScriptStarting": "正在執行 {{file}}...", + "runScriptSessionName": "執行: {{file}}", "copyPath": "複製路徑", "copyRelativePath": "複製相對路徑", "download": "下載", diff --git a/src/web-ui/src/locales/zh-TW/errors.json b/src/web-ui/src/locales/zh-TW/errors.json index 5967e96b20..26dc101566 100644 --- a/src/web-ui/src/locales/zh-TW/errors.json +++ b/src/web-ui/src/locales/zh-TW/errors.json @@ -26,7 +26,9 @@ "createFolderFailed": "Failed to create folder", "renameFailed": "Rename failed", "openInBrowserFailed": "在瀏覽器中開啟檔案失敗", - "revealFailed": "Failed to open file manager" + "revealFailed": "Failed to open file manager", + "runScriptFailed": "執行腳本失敗", + "runScriptUnsupported": "此檔案類型不支援執行腳本" }, "workspace": { "notFound": "工作區未找到", diff --git a/src/web-ui/src/shared/context-menu-system/commands/builtin/file/RunScriptCommand.ts b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/RunScriptCommand.ts new file mode 100644 index 0000000000..3c5737cffd --- /dev/null +++ b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/RunScriptCommand.ts @@ -0,0 +1,127 @@ +import { BaseCommand } from '../../BaseCommand'; +import { CommandResult } from '../../../types/command.types'; +import { MenuContext, ContextType, FileNodeContext, TabContext } from '../../../types/context.types'; +import { i18nService } from '@/infrastructure/i18n'; +import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { isRemoteWorkspace } from '@/shared/types'; +import { hasNonFileUriScheme } from '@/shared/utils/pathUtils'; +import { detectLanguage } from '@/infrastructure/language-detection/utils/helpers'; +import { getTerminalService } from '@/tools/terminal/services/TerminalService'; +import { openShellSessionTarget } from '@/shared/services/openShellSessionTarget'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('RunScriptCommand'); + +/** Language ID → interpreter command prefix. */ +const INTERPRETER_MAP: Record = { + python: 'python', + javascript: 'node', + typescript: 'npx tsx', + shell: 'bash', + powershell: 'powershell -File', + batch: 'cmd /c', + ruby: 'ruby', + go: 'go run', + rust: 'cargo run', + php: 'php', + lua: 'lua', + perl: 'perl', +}; + +/** Windows path-safe quoting for a file path. */ +function quotePath(filePath: string): string { + // Single-quote wrapping with internal single-quote escaping for POSIX shells. + return `'${filePath.replace(/'/g, `'\\''`)}'`; +} + +function getContextFilePath(context: MenuContext): string | undefined { + if (context.type === ContextType.FILE_NODE || context.type === ContextType.FOLDER_NODE) { + return (context as FileNodeContext).filePath; + } + if (context.type === ContextType.TAB) { + return (context as TabContext).filePath; + } + return undefined; +} + +function getContextWorkspacePath(context: MenuContext): string | undefined { + if (context.type === ContextType.FILE_NODE || context.type === ContextType.FOLDER_NODE) { + return (context as FileNodeContext).workspacePath; + } + if (context.type === ContextType.TAB) { + return (context as TabContext).workspacePath; + } + return undefined; +} + +/** Get the interpreter command for a file, or undefined if unsupported. */ +export function getInterpreterForFile(filePath: string): string | undefined { + const result = detectLanguage(filePath); + return INTERPRETER_MAP[result.language.id]; +} + +export class RunScriptCommand extends BaseCommand { + constructor() { + const t = i18nService.getT(); + super({ + id: 'file.run-script', + label: t('common:file.runScript'), + description: t('common:file.runScriptDescription'), + icon: 'Play', + category: 'file', + }); + } + + canExecute(context: MenuContext): boolean { + const currentWorkspace = workspaceManager.getState().currentWorkspace; + if (isRemoteWorkspace(currentWorkspace)) return false; + + const filePath = getContextFilePath(context); + if (!filePath || hasNonFileUriScheme(filePath)) return false; + + return Boolean(getInterpreterForFile(filePath)); + } + + async execute(context: MenuContext): Promise { + const t = i18nService.getT(); + try { + const filePath = getContextFilePath(context); + if (!filePath) { + return this.failure(t('errors:file.runScriptFailed')); + } + + const interpreter = getInterpreterForFile(filePath); + if (!interpreter) { + return this.failure(t('errors:file.runScriptUnsupported')); + } + + const workspacePath = getContextWorkspacePath(context) + ?? workspaceManager.getState().currentWorkspace?.path; + + const service = getTerminalService(); + const session = await service.createSession({ + name: t('common:file.runScriptSessionName', { file: filePath.split(/[/\\]/).pop() }), + workingDirectory: workspacePath, + source: 'manual', + }); + + // Wait for the shell to be ready before sending the command. + await new Promise(resolve => setTimeout(resolve, 600)); + + const command = `${interpreter} ${quotePath(filePath)}`; + await service.sendCommand(session.id, command); + + openShellSessionTarget({ sessionId: session.id, sessionName: session.name }); + + log.info('Script run started', { filePath, interpreter, sessionId: session.id }); + + return this.success(t('common:file.runScriptStarting', { file: filePath.split(/[/\\]/).pop() }), { + sessionId: session.id, + command, + }); + } catch (error) { + log.error('Failed to run script', { error }); + return this.failure(t('errors:file.runScriptFailed'), error as Error); + } + } +} diff --git a/src/web-ui/src/shared/context-menu-system/commands/builtin/file/index.ts b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/index.ts index 2cc303dd1b..60e28ccbfa 100644 --- a/src/web-ui/src/shared/context-menu-system/commands/builtin/file/index.ts +++ b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/index.ts @@ -8,6 +8,7 @@ export * from './CopyPathCommand'; export * from './CopyRelativePathCommand'; export * from './RevealInExplorerCommand'; export * from './OpenHtmlInBrowserCommand'; +export * from './RunScriptCommand'; import { NewFileCommand } from './NewFileCommand'; import { NewFolderCommand } from './NewFolderCommand'; @@ -17,6 +18,7 @@ import { CopyPathCommand } from './CopyPathCommand'; import { CopyRelativePathCommand } from './CopyRelativePathCommand'; import { RevealInExplorerCommand } from './RevealInExplorerCommand'; import { OpenHtmlInBrowserCommand } from './OpenHtmlInBrowserCommand'; +import { RunScriptCommand } from './RunScriptCommand'; export function getFileCommands() { @@ -28,7 +30,8 @@ export function getFileCommands() { new CopyPathCommand(), new CopyRelativePathCommand(), new RevealInExplorerCommand(), - new OpenHtmlInBrowserCommand() + new OpenHtmlInBrowserCommand(), + new RunScriptCommand() ]; }