diff --git a/src/renderer/src/components/editor/editor-panel-render-model.test.ts b/src/renderer/src/components/editor/editor-panel-render-model.test.ts index 95deda13558..78d5896784b 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.test.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.test.ts @@ -129,6 +129,33 @@ describe('getEditorPanelRenderModel HTML preview affordance', () => { }) }) +describe('getEditorPanelRenderModel shebang language fallback', () => { + it('uses the shebang when an extensionless file has no name-based language', () => { + const scriptFile: OpenFile = { + id: '/repo/bin/deploy', + filePath: '/repo/bin/deploy', + relativePath: 'bin/deploy', + worktreeId: 'wt-1', + language: 'plaintext', + mode: 'edit', + isDirty: false + } + const model = renderModel({ + activeFile: scriptFile, + fileContents: { + [scriptFile.id]: textContent({ content: '#!/usr/bin/env python3\nprint(1)' }) + } + }) + + expect(model.resolvedLanguage).toBe('python') + const diffModel = renderModel({ + activeFile: { ...scriptFile, mode: 'diff', diffSource: 'unstaged' }, + editorDrafts: { [scriptFile.id]: '#!/usr/bin/env python3\nprint(2)' } + }) + expect(diffModel.resolvedLanguage).toBe('plaintext') + }) +}) + describe('getEditorPanelRenderModel read-only raw rendering (AI Vault View Log)', () => { it('renders a read-only markdown log as raw source with no markdown viewer or chrome', () => { const model = renderModel({ activeFile: markdownFile({ readOnly: true }) }) diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index cbabb17a792..86fdce02ebb 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -54,10 +54,14 @@ export function getEditorPanelRenderModel({ activeFile.diffSource === 'combined-uncommitted' || activeFile.diffSource === 'combined-branch' || activeFile.diffSource === 'combined-commit') + // Why: diff drafts vanish on save, so shebang detection there would flicker; diffs stay name-based. const resolvedLanguage = activeFile.mode === 'diff' ? detectLanguage(activeFile.relativePath) - : detectLanguage(activeFile.filePath) + : detectLanguage( + activeFile.filePath, + editorDrafts[activeFile.id] ?? fileContents[activeFile.id]?.content + ) // Read-only artifact tabs require raw source viewers; retain real-language tokenization while suppressing rich/preview modes and their editing affordances. const rawReadOnly = activeFile.mode === 'edit' && activeFile.readOnly === true const viewerLanguage = rawReadOnly ? 'plaintext' : resolvedLanguage diff --git a/src/shared/editor-language/editor-language-detect.test.ts b/src/shared/editor-language/editor-language-detect.test.ts index d3c47efb29e..e20c545390a 100644 --- a/src/shared/editor-language/editor-language-detect.test.ts +++ b/src/shared/editor-language/editor-language-detect.test.ts @@ -254,4 +254,25 @@ describe('detectLanguage', () => { ])('maps exact filenames case-insensitively: %s', (filePath, expected) => { expect(detectLanguage(filePath)).toBe(expected) }) + it.each([ + ['#!/usr/bin/python3\n', 'python'], + ['#!/usr/bin/env bash\necho hi', 'shell'], + ['#! /bin/sh -e\n', 'shell'], + ['#!/usr/bin/env -S node --flag', 'javascript'], + ['#!C:\\Python\\python.exe\n', 'python'], + ['#!/usr/bin/env python3.12', 'python'], + ['#!/usr/bin/ruby\r\nputs 1', 'ruby'], + ['#!/usr/bin/env awk', 'plaintext'], + ['#!/usr/bin/env toString', 'plaintext'], + ['#!/usr/bin/__proto__', 'plaintext'], + ['echo hi\n#!/bin/sh', 'plaintext'] + ])('falls back to the shebang for extensionless files: %j', (content, expected) => { + expect(detectLanguage('bin/tool', content)).toBe(expected) + }) + + it('only applies the shebang to extensionless names', () => { + expect(detectLanguage('script.rb', '#!/usr/bin/env python3')).toBe('ruby') + expect(detectLanguage('Makefile', '#!/usr/bin/env python3')).toBe('makefile') + expect(detectLanguage('notes.txt', '#!/usr/bin/env python3')).toBe('plaintext') + }) }) diff --git a/src/shared/editor-language/editor-language-detect.ts b/src/shared/editor-language/editor-language-detect.ts index fb607068203..01732b6bd51 100644 --- a/src/shared/editor-language/editor-language-detect.ts +++ b/src/shared/editor-language/editor-language-detect.ts @@ -1,3 +1,4 @@ +import { parseSetupScriptShebang } from '../setup-script-shebang' import { detectMonacoFilenameLanguage } from './monaco-filename-language' function extname(filePath: string): string { @@ -145,7 +146,43 @@ const FILENAME_LOWER_TO_LANGUAGE: Record = Object.fromEntries( Object.entries(FILENAME_TO_LANGUAGE).map(([name, language]) => [name.toLowerCase(), language]) ) -export function detectLanguage(filePath: string): string { +const SHEBANG_INTERPRETER_TO_LANGUAGE: Record = { + sh: 'shell', + bash: 'shell', + zsh: 'shell', + dash: 'shell', + ksh: 'shell', + ash: 'shell', + fish: 'shell', + python: 'python', + pypy: 'python', + node: 'javascript', + nodejs: 'javascript', + deno: 'javascript', + bun: 'javascript', + 'ts-node': 'typescript', + tsx: 'typescript', + ruby: 'ruby', + perl: 'perl', + php: 'php', + lua: 'lua', + luajit: 'lua', + pwsh: 'powershell', + powershell: 'powershell' +} + +function detectShebangLanguage(content: string): string | null { + // Why: scan a bounded prefix so per-keystroke detection stays cheap on large files. + const interpreter = parseSetupScriptShebang(content.slice(0, 256))?.interpreter.replace( + /[\d.]+$/, + '' + ) + return interpreter !== undefined && Object.hasOwn(SHEBANG_INTERPRETER_TO_LANGUAGE, interpreter) + ? SHEBANG_INTERPRETER_TO_LANGUAGE[interpreter] + : null +} + +export function detectLanguage(filePath: string, content?: string): string { const parts = filePath.split(/[\\/]/) const filename = parts.at(-1)! if (Object.hasOwn(FILENAME_TO_LANGUAGE, filename)) { @@ -159,9 +196,13 @@ export function detectLanguage(filePath: string): string { const ext = extname(filename).toLowerCase() const lowerName = filename.toLowerCase() // Scoped dotenv names fall back to INI only when no specific extension matches. - return ( + const language = EXT_TO_LANGUAGE[ext] ?? detectMonacoFilenameLanguage(filename) ?? (lowerName === '.env' || lowerName.startsWith('.env.') ? 'ini' : 'plaintext') - ) + // Why: only extensionless names fall back to the shebang; unmapped extensions like .txt stay plaintext. + if (language === 'plaintext' && ext === '' && content !== undefined) { + return detectShebangLanguage(content) ?? 'plaintext' + } + return language }