Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -227,6 +228,19 @@ export const Tab: React.FC<TabProps> = ({
},
);

// 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',
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/locales/en-US/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/web-ui/src/locales/en-US/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/locales/zh-CN/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,10 @@
"openWith": "打开方式",
"openInBrowser": "在浏览器中打开",
"reveal": "在资源管理器中显示",
"runScript": "运行脚本",
"runScriptDescription": "在终端中运行此脚本文件",
"runScriptStarting": "正在运行 {{file}}...",
"runScriptSessionName": "运行: {{file}}",
"copyPath": "复制路径",
"copyRelativePath": "复制相对路径",
"download": "下载",
Expand Down
4 changes: 3 additions & 1 deletion src/web-ui/src/locales/zh-CN/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "工作区未找到",
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/locales/zh-TW/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,10 @@
"openWith": "開啟方式",
"openInBrowser": "在瀏覽器中開啟",
"reveal": "在資源管理器中顯示",
"runScript": "執行腳本",
"runScriptDescription": "在終端中執行此腳本檔案",
"runScriptStarting": "正在執行 {{file}}...",
"runScriptSessionName": "執行: {{file}}",
"copyPath": "複製路徑",
"copyRelativePath": "複製相對路徑",
"download": "下載",
Expand Down
4 changes: 3 additions & 1 deletion src/web-ui/src/locales/zh-TW/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "工作區未找到",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<CommandResult> {
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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() {
Expand All @@ -28,7 +30,8 @@ export function getFileCommands() {
new CopyPathCommand(),
new CopyRelativePathCommand(),
new RevealInExplorerCommand(),
new OpenHtmlInBrowserCommand()
new OpenHtmlInBrowserCommand(),
new RunScriptCommand()
];
}