189 lines
5.5 KiB
TypeScript
189 lines
5.5 KiB
TypeScript
import {
|
|
expandPastedTextRefs,
|
|
formatPastedTextRef,
|
|
getPastedTextRefNumLines,
|
|
} from '../history.js'
|
|
import instances from '../ink/instances.js'
|
|
import type { PastedContent } from './config.js'
|
|
import { classifyGuiEditor, getExternalEditor } from './editor.js'
|
|
import { execSync_DEPRECATED } from './execSyncWrapper.js'
|
|
import { getFsImplementation } from './fsOperations.js'
|
|
import { toIDEDisplayName } from './ide.js'
|
|
import { writeFileSync_DEPRECATED } from './slowOperations.js'
|
|
import { generateTempFilePath } from './tempfile.js'
|
|
|
|
// Map of editor command overrides (e.g., to add wait flags)
|
|
const EDITOR_OVERRIDES: Record<string, string> = {
|
|
code: 'code -w', // VS Code: wait for file to be closed
|
|
subl: 'subl --wait', // Sublime Text: wait for file to be closed
|
|
}
|
|
|
|
function isGuiEditor(editor: string): boolean {
|
|
return classifyGuiEditor(editor) !== undefined
|
|
}
|
|
|
|
export type EditorResult = {
|
|
content: string | null
|
|
error?: string
|
|
}
|
|
|
|
// sync IO: called from sync context (React components, sync command handlers)
|
|
export function editFileInEditor(filePath: string): EditorResult {
|
|
const fs = getFsImplementation()
|
|
const inkInstance = instances.get(process.stdout)
|
|
if (!inkInstance) {
|
|
throw new Error('Ink instance not found - cannot pause rendering')
|
|
}
|
|
|
|
const editor = getExternalEditor()
|
|
if (!editor) {
|
|
return { content: null }
|
|
}
|
|
|
|
try {
|
|
fs.statSync(filePath)
|
|
} catch {
|
|
return { content: null }
|
|
}
|
|
|
|
const useAlternateScreen = !isGuiEditor(editor)
|
|
|
|
if (useAlternateScreen) {
|
|
// Terminal editors (vi, nano, etc.) take over the terminal. Delegate to
|
|
// Ink's alt-screen-aware handoff so fullscreen mode (where <AlternateScreen>
|
|
// already entered alt screen) doesn't get knocked back to the main buffer
|
|
// by a hardcoded ?1049l. enterAlternateScreen() internally calls pause()
|
|
// and suspendStdin(); exitAlternateScreen() undoes both and resets frame
|
|
// state so the next render writes from scratch.
|
|
inkInstance.enterAlternateScreen()
|
|
} else {
|
|
// GUI editors (code, subl, etc.) open in a separate window — just pause
|
|
// Ink and release stdin while they're open.
|
|
inkInstance.pause()
|
|
inkInstance.suspendStdin()
|
|
}
|
|
|
|
try {
|
|
// Use override command if available, otherwise use the editor as-is
|
|
const editorCommand = EDITOR_OVERRIDES[editor] ?? editor
|
|
execSync_DEPRECATED(`${editorCommand} "${filePath}"`, {
|
|
stdio: 'inherit',
|
|
})
|
|
|
|
// Read the edited content
|
|
const editedContent = fs.readFileSync(filePath, { encoding: 'utf-8' })
|
|
return { content: editedContent }
|
|
} catch (err) {
|
|
if (
|
|
typeof err === 'object' &&
|
|
err !== null &&
|
|
'status' in err &&
|
|
typeof (err as { status: unknown }).status === 'number'
|
|
) {
|
|
const status = (err as { status: number }).status
|
|
if (status !== 0) {
|
|
const editorName = toIDEDisplayName(editor)
|
|
return {
|
|
content: null,
|
|
error: `${editorName} exited with code ${status}`,
|
|
}
|
|
}
|
|
}
|
|
return { content: null }
|
|
} finally {
|
|
if (useAlternateScreen) {
|
|
inkInstance.exitAlternateScreen()
|
|
} else {
|
|
inkInstance.resumeStdin()
|
|
inkInstance.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-collapse expanded pasted text by finding content that matches
|
|
* pastedContents and replacing it with references.
|
|
*/
|
|
function recollapsePastedContent(
|
|
editedPrompt: string,
|
|
originalPrompt: string,
|
|
pastedContents: Record<number, PastedContent>,
|
|
): string {
|
|
let collapsed = editedPrompt
|
|
|
|
// Find pasted content in the edited text and re-collapse it
|
|
for (const [id, content] of Object.entries(pastedContents)) {
|
|
if (content.type === 'text') {
|
|
const pasteId = parseInt(id)
|
|
const contentStr = content.content
|
|
|
|
// Check if this exact content exists in the edited prompt
|
|
const contentIndex = collapsed.indexOf(contentStr)
|
|
if (contentIndex !== -1) {
|
|
// Replace with reference
|
|
const numLines = getPastedTextRefNumLines(contentStr)
|
|
const ref = formatPastedTextRef(pasteId, numLines)
|
|
collapsed =
|
|
collapsed.slice(0, contentIndex) +
|
|
ref +
|
|
collapsed.slice(contentIndex + contentStr.length)
|
|
}
|
|
}
|
|
}
|
|
|
|
return collapsed
|
|
}
|
|
|
|
// sync IO: called from sync context (React components, sync command handlers)
|
|
export function editPromptInEditor(
|
|
currentPrompt: string,
|
|
pastedContents?: Record<number, PastedContent>,
|
|
): EditorResult {
|
|
const fs = getFsImplementation()
|
|
const tempFile = generateTempFilePath()
|
|
|
|
try {
|
|
// Expand any pasted text references before editing
|
|
const expandedPrompt = pastedContents
|
|
? expandPastedTextRefs(currentPrompt, pastedContents)
|
|
: currentPrompt
|
|
|
|
// Write expanded prompt to temp file
|
|
writeFileSync_DEPRECATED(tempFile, expandedPrompt, {
|
|
encoding: 'utf-8',
|
|
flush: true,
|
|
})
|
|
|
|
// Delegate to editFileInEditor
|
|
const result = editFileInEditor(tempFile)
|
|
|
|
if (result.content === null) {
|
|
return result
|
|
}
|
|
|
|
// Trim a single trailing newline if present (common editor behavior)
|
|
let finalContent = result.content
|
|
if (finalContent.endsWith('\n') && !finalContent.endsWith('\n\n')) {
|
|
finalContent = finalContent.slice(0, -1)
|
|
}
|
|
|
|
// Re-collapse pasted content if it wasn't edited
|
|
if (pastedContents) {
|
|
finalContent = recollapsePastedContent(
|
|
finalContent,
|
|
currentPrompt,
|
|
pastedContents,
|
|
)
|
|
}
|
|
|
|
return { content: finalContent }
|
|
} finally {
|
|
// Clean up temp file
|
|
try {
|
|
fs.unlinkSync(tempFile)
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
}
|