mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 08:25:00 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 047a8dc573 | |||
| a897a04700 | |||
| 2a392f76b8 | |||
| b3f83eb104 | |||
| 9fec5ee87d |
@@ -526,12 +526,9 @@ export function Prompt(props: PromptProps) {
|
||||
const content = await Editor.open({
|
||||
value,
|
||||
renderer,
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
cwd: project.instance.cwd(),
|
||||
})
|
||||
if (!content) return
|
||||
if (content === undefined) return
|
||||
|
||||
input.setText(content)
|
||||
|
||||
|
||||
@@ -79,6 +79,13 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
directory() {
|
||||
return store.instance.path.directory
|
||||
},
|
||||
cwd() {
|
||||
return (
|
||||
(store.instance.path.worktree === "/" ? undefined : store.instance.path.worktree) ||
|
||||
store.instance.path.directory ||
|
||||
process.cwd()
|
||||
)
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
current() {
|
||||
|
||||
@@ -972,13 +972,10 @@ export function Session() {
|
||||
|
||||
if (options.openWithoutSaving) {
|
||||
// Just open in editor without saving
|
||||
await Editor.open({
|
||||
await Editor.openTemporary({
|
||||
value: transcript,
|
||||
renderer,
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
cwd: project.instance.cwd(),
|
||||
})
|
||||
} else {
|
||||
const exportDir = process.cwd()
|
||||
@@ -986,24 +983,24 @@ export function Session() {
|
||||
const filepath = path.join(exportDir, filename)
|
||||
|
||||
await Filesystem.write(filepath, transcript)
|
||||
|
||||
// Open with EDITOR if available
|
||||
const result = await Editor.open({
|
||||
value: transcript,
|
||||
const error = await Editor.openFile({
|
||||
filepath,
|
||||
renderer,
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
cwd: project.instance.cwd(),
|
||||
directory: project.instance.directory() || process.cwd(),
|
||||
})
|
||||
if (result !== undefined) {
|
||||
await Filesystem.write(filepath, result)
|
||||
}
|
||||
.then(() => undefined)
|
||||
.catch(errorMessage)
|
||||
|
||||
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
|
||||
toast.show({
|
||||
message: error
|
||||
? `Session exported to ${filename}, but failed to open file: ${error}`
|
||||
: `Session exported to ${filename}`,
|
||||
variant: error ? "warning" : "success",
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast.show({ message: "Failed to export session", variant: "error" })
|
||||
} catch (error) {
|
||||
toast.show({ message: `Failed to export session: ${errorMessage(error)}`, variant: "error" })
|
||||
}
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -1744,6 +1741,20 @@ type ToolProps<T> = {
|
||||
output?: string
|
||||
part: ToolPart
|
||||
}
|
||||
|
||||
function useOpenFile() {
|
||||
const project = useProject()
|
||||
const toast = useToast()
|
||||
const renderer = useRenderer()
|
||||
return (filePath: string | undefined) => {
|
||||
if (!filePath) return
|
||||
const cwd = project.instance.cwd()
|
||||
void Editor.openFile({ filepath: filePath, renderer, cwd, directory: project.instance.directory() || cwd }).catch(
|
||||
(error) => toast.show({ message: `Failed to open file: ${errorMessage(error)}`, variant: "error" }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function GenericTool(props: ToolProps<any>) {
|
||||
const { theme } = useTheme()
|
||||
const ctx = use()
|
||||
@@ -1962,6 +1973,7 @@ function BlockTool(props: {
|
||||
title: string
|
||||
children: JSX.Element
|
||||
onClick?: () => void
|
||||
onTitleClick?: () => void
|
||||
part?: ToolPart
|
||||
spinner?: boolean
|
||||
}) {
|
||||
@@ -1991,7 +2003,16 @@ function BlockTool(props: {
|
||||
<Show
|
||||
when={props.spinner}
|
||||
fallback={
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
<text
|
||||
paddingLeft={3}
|
||||
fg={hover() && props.onTitleClick ? theme.text : theme.textMuted}
|
||||
onMouseOver={() => props.onTitleClick && setHover(true)}
|
||||
onMouseOut={() => props.onTitleClick && setHover(false)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
props.onTitleClick?.()
|
||||
}}
|
||||
>
|
||||
{props.title}
|
||||
</text>
|
||||
}
|
||||
@@ -2067,6 +2088,8 @@ function Shell(props: ToolProps<typeof ShellTool>) {
|
||||
function Write(props: ToolProps<typeof WriteTool>) {
|
||||
const { theme, syntax } = useTheme()
|
||||
const pathFormatter = usePathFormatter()
|
||||
const openFile = useOpenFile()
|
||||
const filePath = createMemo(() => props.metadata.filepath ?? props.input.filePath)
|
||||
const code = createMemo(() => {
|
||||
if (!props.input.content) return ""
|
||||
return props.input.content
|
||||
@@ -2075,7 +2098,11 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diagnostics !== undefined}>
|
||||
<BlockTool title={"# Wrote " + pathFormatter.format(props.input.filePath)} part={props.part}>
|
||||
<BlockTool
|
||||
title={"# Wrote " + pathFormatter.format(props.input.filePath)}
|
||||
part={props.part}
|
||||
onTitleClick={props.part.state.status === "completed" ? () => openFile(filePath()) : undefined}
|
||||
>
|
||||
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
conceal={false}
|
||||
@@ -2089,7 +2116,13 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
|
||||
<InlineTool
|
||||
icon="←"
|
||||
pending="Preparing write..."
|
||||
complete={props.input.filePath}
|
||||
part={props.part}
|
||||
onClick={props.part.state.status === "completed" ? () => openFile(filePath()) : undefined}
|
||||
>
|
||||
Write {pathFormatter.format(props.input.filePath)}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
@@ -2287,6 +2320,8 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
const ctx = use()
|
||||
const { theme, syntax } = useTheme()
|
||||
const pathFormatter = usePathFormatter()
|
||||
const openFile = useOpenFile()
|
||||
const filePath = createMemo(() => props.metadata.filediff?.file ?? props.input.filePath)
|
||||
|
||||
const view = createMemo(() => {
|
||||
const diffStyle = ctx.tui.diff_style
|
||||
@@ -2302,7 +2337,11 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diff !== undefined}>
|
||||
<BlockTool title={"← Edit " + pathFormatter.format(props.input.filePath)} part={props.part}>
|
||||
<BlockTool
|
||||
title={"← Edit " + pathFormatter.format(props.input.filePath)}
|
||||
part={props.part}
|
||||
onTitleClick={props.part.state.status === "completed" ? () => openFile(filePath()) : undefined}
|
||||
>
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
diff={diffContent()}
|
||||
@@ -2328,7 +2367,13 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
|
||||
<InlineTool
|
||||
icon="←"
|
||||
pending="Preparing edit..."
|
||||
complete={props.input.filePath}
|
||||
part={props.part}
|
||||
onClick={props.part.state.status === "completed" ? () => openFile(filePath()) : undefined}
|
||||
>
|
||||
Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
@@ -2340,6 +2385,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||
const ctx = use()
|
||||
const { theme, syntax } = useTheme()
|
||||
const pathFormatter = usePathFormatter()
|
||||
const openFile = useOpenFile()
|
||||
|
||||
const files = createMemo(() => props.metadata.files ?? [])
|
||||
|
||||
@@ -2387,7 +2433,15 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||
<Match when={files().length > 0}>
|
||||
<For each={files()}>
|
||||
{(file) => (
|
||||
<BlockTool title={title(file)} part={props.part}>
|
||||
<BlockTool
|
||||
title={title(file)}
|
||||
part={props.part}
|
||||
onTitleClick={
|
||||
props.part.state.status !== "completed" || file.type === "delete"
|
||||
? undefined
|
||||
: () => openFile(file.movePath ?? file.filePath)
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={file.type !== "delete"}
|
||||
fallback={
|
||||
|
||||
@@ -1,23 +1,74 @@
|
||||
import { defer } from "@/util/defer"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { CliRenderer } from "@opentui/core"
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import systemOpen from "open"
|
||||
|
||||
export async function open(opts: { value: string; renderer: CliRenderer; cwd?: string }): Promise<string | undefined> {
|
||||
const editor = process.env["VISUAL"] || process.env["EDITOR"]
|
||||
export async function open(opts: { value: string; renderer: CliRenderer; cwd: string }): Promise<string | undefined> {
|
||||
const editor = configuredEditor()
|
||||
if (!editor) return
|
||||
|
||||
const filepath = join(tmpdir(), `${Date.now()}.md`)
|
||||
await using _ = defer(async () => rm(filepath, { force: true }))
|
||||
const draft = await createDraft(opts.value)
|
||||
await using _ = defer(draft.remove)
|
||||
await openEditor(draft.filepath, opts, editor)
|
||||
return Filesystem.readText(draft.filepath)
|
||||
}
|
||||
|
||||
await Filesystem.write(filepath, opts.value)
|
||||
export async function openTemporary(opts: { value: string; renderer: CliRenderer; cwd: string }) {
|
||||
const draft = await createDraft(opts.value)
|
||||
const mode = await openPath(draft.filepath, opts).catch(async (error) => {
|
||||
await draft.remove()
|
||||
throw error
|
||||
})
|
||||
// System openers detach, so retain the draft in the OS temp dir for the application to read.
|
||||
if (mode === "system") return
|
||||
await draft.remove()
|
||||
}
|
||||
|
||||
export async function openFile(opts: { filepath: string; renderer: CliRenderer; cwd: string; directory: string }) {
|
||||
await openPath(Filesystem.resolveFilePath(opts.directory, opts.filepath), opts)
|
||||
}
|
||||
|
||||
async function createDraft(value: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "opencode-editor-"))
|
||||
const filepath = join(dir, "draft.md")
|
||||
await Filesystem.write(filepath, value).catch(async (error) => {
|
||||
await rm(dir, { force: true, recursive: true })
|
||||
throw error
|
||||
})
|
||||
return {
|
||||
filepath,
|
||||
remove: () => rm(dir, { force: true, recursive: true }),
|
||||
}
|
||||
}
|
||||
|
||||
async function openPath(filepath: string, opts: { renderer: CliRenderer; cwd: string }) {
|
||||
const editor = configuredEditor()
|
||||
if (editor) {
|
||||
await openEditor(filepath, opts, editor).catch((error) => {
|
||||
throw new Error(`Failed to open file with ${editor}: ${errorMessage(error)}. Check $VISUAL or $EDITOR.`)
|
||||
})
|
||||
return "editor" as const
|
||||
}
|
||||
await systemOpen(filepath).catch((error) => {
|
||||
throw new Error(`Failed to open file: ${errorMessage(error)}. Set $VISUAL or $EDITOR.`)
|
||||
})
|
||||
return "system" as const
|
||||
}
|
||||
|
||||
function configuredEditor() {
|
||||
return process.env["VISUAL"]?.trim() || process.env["EDITOR"]?.trim()
|
||||
}
|
||||
|
||||
async function openEditor(filepath: string, opts: { renderer: CliRenderer; cwd: string }, editor: string) {
|
||||
opts.renderer.suspend()
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
try {
|
||||
const parts = editor.split(" ")
|
||||
const parts = editor.split(/\s+/)
|
||||
const proc = Process.spawn([...parts, filepath], {
|
||||
cwd: opts.cwd,
|
||||
stdin: "inherit",
|
||||
@@ -25,9 +76,8 @@ export async function open(opts: { value: string; renderer: CliRenderer; cwd?: s
|
||||
stderr: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
await proc.exited
|
||||
const content = await Filesystem.readText(filepath)
|
||||
return content || undefined
|
||||
const code = await proc.exited
|
||||
if (code !== 0) throw new Error(`Editor exited with code ${code}`)
|
||||
} finally {
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
opts.renderer.resume()
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { afterEach, expect, mock, test } from "bun:test"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { dirname, join } from "node:path"
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import { errorMessage } from "../../../src/util/error"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
|
||||
const originalVisual = process.env.VISUAL
|
||||
const originalEditor = process.env.EDITOR
|
||||
const systemOpened: string[] = []
|
||||
const retained = new Set<string>()
|
||||
let systemOpenError: Error | undefined
|
||||
|
||||
void mock.module("open", () => ({
|
||||
default: async (filepath: string) => {
|
||||
if (systemOpenError) throw systemOpenError
|
||||
systemOpened.push(filepath)
|
||||
},
|
||||
}))
|
||||
|
||||
const Editor = await import("../../../src/cli/cmd/tui/util/editor")
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalVisual === undefined) delete process.env.VISUAL
|
||||
else process.env.VISUAL = originalVisual
|
||||
if (originalEditor === undefined) delete process.env.EDITOR
|
||||
else process.env.EDITOR = originalEditor
|
||||
systemOpened.length = 0
|
||||
systemOpenError = undefined
|
||||
await Promise.all([...retained].map((dir) => rm(dir, { force: true, recursive: true })))
|
||||
retained.clear()
|
||||
})
|
||||
|
||||
function renderer() {
|
||||
const events: string[] = []
|
||||
return {
|
||||
events,
|
||||
value: {
|
||||
suspend() {
|
||||
events.push("suspend")
|
||||
},
|
||||
currentRenderBuffer: {
|
||||
clear() {
|
||||
events.push("clear")
|
||||
},
|
||||
},
|
||||
resume() {
|
||||
events.push("resume")
|
||||
},
|
||||
requestRender() {
|
||||
events.push("render")
|
||||
},
|
||||
} as unknown as CliRenderer,
|
||||
}
|
||||
}
|
||||
|
||||
async function editor(dir: string, name: string, source: string) {
|
||||
const filepath = join(dir, `${name}.ts`)
|
||||
await Bun.write(filepath, source)
|
||||
return `${process.execPath} ${filepath}`
|
||||
}
|
||||
|
||||
test("open returns without suspending the renderer when no editor is configured", async () => {
|
||||
delete process.env.VISUAL
|
||||
delete process.env.EDITOR
|
||||
const render = renderer()
|
||||
|
||||
expect(await Editor.open({ value: "secret", renderer: render.value, cwd: process.cwd() })).toBeUndefined()
|
||||
expect(render.events).toEqual([])
|
||||
})
|
||||
|
||||
test("openFile prefers VISUAL and separates file resolution from editor cwd", async () => {
|
||||
await using directory = await tmpdir()
|
||||
await using cwd = await tmpdir()
|
||||
const target = join(directory.path, "target.md")
|
||||
const marker = join(directory.path, "marker.txt")
|
||||
await Bun.write(target, "target")
|
||||
process.env.VISUAL = await editor(
|
||||
directory.path,
|
||||
"visual",
|
||||
`await Bun.write(${JSON.stringify(marker)}, process.cwd() + "\\n" + process.argv.at(-1))`,
|
||||
)
|
||||
process.env.EDITOR = await editor(directory.path, "editor", "process.exit(7)")
|
||||
const render = renderer()
|
||||
|
||||
await Editor.openFile({ filepath: "target.md", renderer: render.value, cwd: cwd.path, directory: directory.path })
|
||||
|
||||
expect(await Bun.file(marker).text()).toBe(`${cwd.path}\n${target}`)
|
||||
expect(render.events).toEqual(["suspend", "clear", "clear", "resume", "render"])
|
||||
})
|
||||
|
||||
test("openFile falls back to EDITOR when VISUAL is empty", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const target = join(tmp.path, "target.md")
|
||||
const marker = join(tmp.path, "marker.txt")
|
||||
await Bun.write(target, "target")
|
||||
process.env.VISUAL = ""
|
||||
process.env.EDITOR = await editor(
|
||||
tmp.path,
|
||||
"editor",
|
||||
`await Bun.write(${JSON.stringify(marker)}, process.argv.at(-1)!)`,
|
||||
)
|
||||
|
||||
await Editor.openFile({ filepath: target, renderer: renderer().value, cwd: tmp.path, directory: tmp.path })
|
||||
|
||||
expect(await Bun.file(marker).text()).toBe(target)
|
||||
})
|
||||
|
||||
test("open returns empty edited content and removes its draft", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const marker = join(tmp.path, "marker.txt")
|
||||
process.env.VISUAL = await editor(
|
||||
tmp.path,
|
||||
"editor",
|
||||
`const filepath = process.argv.at(-1)!; await Bun.write(${JSON.stringify(marker)}, filepath); await Bun.write(filepath, "")`,
|
||||
)
|
||||
|
||||
expect(await Editor.open({ value: "draft", renderer: renderer().value, cwd: tmp.path })).toBe("")
|
||||
expect(await draftExists(marker)).toBeFalse()
|
||||
})
|
||||
|
||||
test("openTemporary removes drafts after blocking editors exit", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const marker = join(tmp.path, "marker.txt")
|
||||
process.env.VISUAL = await editor(
|
||||
tmp.path,
|
||||
"editor",
|
||||
`await Bun.write(${JSON.stringify(marker)}, process.argv.at(-1)!)`,
|
||||
)
|
||||
|
||||
await Editor.openTemporary({ value: "transcript", renderer: renderer().value, cwd: tmp.path })
|
||||
|
||||
expect(await draftExists(marker)).toBeFalse()
|
||||
})
|
||||
|
||||
test("openTemporary retains drafts opened by the platform application", async () => {
|
||||
delete process.env.VISUAL
|
||||
delete process.env.EDITOR
|
||||
|
||||
await Editor.openTemporary({ value: "transcript", renderer: renderer().value, cwd: process.cwd() })
|
||||
|
||||
expect(systemOpened).toHaveLength(1)
|
||||
const filepath = systemOpened[0]
|
||||
expect(filepath).toBeDefined()
|
||||
if (!filepath) return
|
||||
retained.add(dirname(filepath))
|
||||
expect(await Bun.file(filepath).text()).toBe("transcript")
|
||||
})
|
||||
|
||||
test("openFile uses the platform application when no editor is configured", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
delete process.env.VISUAL
|
||||
delete process.env.EDITOR
|
||||
const target = join(tmp.path, "target.md")
|
||||
await Bun.write(target, "target")
|
||||
|
||||
await Editor.openFile({ filepath: "target.md", renderer: renderer().value, cwd: tmp.path, directory: tmp.path })
|
||||
|
||||
expect(systemOpened).toEqual([target])
|
||||
})
|
||||
|
||||
test("openFile suggests configuring an editor when the platform application fails", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
delete process.env.VISUAL
|
||||
delete process.env.EDITOR
|
||||
systemOpenError = new Error("spawn xdg-open ENOENT")
|
||||
|
||||
const message = await Editor.openFile({
|
||||
filepath: "target.md",
|
||||
renderer: renderer().value,
|
||||
cwd: tmp.path,
|
||||
directory: tmp.path,
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch(errorMessage)
|
||||
|
||||
expect(message).toBe("Failed to open file: spawn xdg-open ENOENT. Set $VISUAL or $EDITOR.")
|
||||
})
|
||||
|
||||
test("openFile restores the renderer when the editor exits unsuccessfully", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
process.env.VISUAL = await editor(tmp.path, "editor", "process.exit(7)")
|
||||
const target = join(tmp.path, "target.md")
|
||||
await Bun.write(target, "target")
|
||||
const render = renderer()
|
||||
|
||||
const message = await Editor.openFile({
|
||||
filepath: target,
|
||||
renderer: render.value,
|
||||
cwd: tmp.path,
|
||||
directory: tmp.path,
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch(errorMessage)
|
||||
expect(message).toBe(
|
||||
`Failed to open file with ${process.execPath} ${join(tmp.path, "editor.ts")}: Editor exited with code 7. Check $VISUAL or $EDITOR.`,
|
||||
)
|
||||
expect(render.events).toEqual(["suspend", "clear", "clear", "resume", "render"])
|
||||
})
|
||||
|
||||
async function draftExists(marker: string) {
|
||||
return Bun.file(await Bun.file(marker).text()).exists()
|
||||
}
|
||||
Reference in New Issue
Block a user