Compare commits
5 commits
dev
...
dustin/cli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
047a8dc573 | ||
|
|
a897a04700 | ||
|
|
2a392f76b8 | ||
|
|
b3f83eb104 | ||
|
|
9fec5ee87d |
5 changed files with 352 additions and 41 deletions
|
|
@ -526,12 +526,9 @@ export function Prompt(props: PromptProps) {
|
||||||
const content = await Editor.open({
|
const content = await Editor.open({
|
||||||
value,
|
value,
|
||||||
renderer,
|
renderer,
|
||||||
cwd:
|
cwd: project.instance.cwd(),
|
||||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
|
||||||
project.instance.directory() ||
|
|
||||||
process.cwd(),
|
|
||||||
})
|
})
|
||||||
if (!content) return
|
if (content === undefined) return
|
||||||
|
|
||||||
input.setText(content)
|
input.setText(content)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,13 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||||
directory() {
|
directory() {
|
||||||
return store.instance.path.directory
|
return store.instance.path.directory
|
||||||
},
|
},
|
||||||
|
cwd() {
|
||||||
|
return (
|
||||||
|
(store.instance.path.worktree === "/" ? undefined : store.instance.path.worktree) ||
|
||||||
|
store.instance.path.directory ||
|
||||||
|
process.cwd()
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
current() {
|
current() {
|
||||||
|
|
|
||||||
|
|
@ -972,13 +972,10 @@ export function Session() {
|
||||||
|
|
||||||
if (options.openWithoutSaving) {
|
if (options.openWithoutSaving) {
|
||||||
// Just open in editor without saving
|
// Just open in editor without saving
|
||||||
await Editor.open({
|
await Editor.openTemporary({
|
||||||
value: transcript,
|
value: transcript,
|
||||||
renderer,
|
renderer,
|
||||||
cwd:
|
cwd: project.instance.cwd(),
|
||||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
|
||||||
project.instance.directory() ||
|
|
||||||
process.cwd(),
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const exportDir = process.cwd()
|
const exportDir = process.cwd()
|
||||||
|
|
@ -986,24 +983,24 @@ export function Session() {
|
||||||
const filepath = path.join(exportDir, filename)
|
const filepath = path.join(exportDir, filename)
|
||||||
|
|
||||||
await Filesystem.write(filepath, transcript)
|
await Filesystem.write(filepath, transcript)
|
||||||
|
const error = await Editor.openFile({
|
||||||
// Open with EDITOR if available
|
filepath,
|
||||||
const result = await Editor.open({
|
|
||||||
value: transcript,
|
|
||||||
renderer,
|
renderer,
|
||||||
cwd:
|
cwd: project.instance.cwd(),
|
||||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
directory: project.instance.directory() || process.cwd(),
|
||||||
project.instance.directory() ||
|
|
||||||
process.cwd(),
|
|
||||||
})
|
})
|
||||||
if (result !== undefined) {
|
.then(() => undefined)
|
||||||
await Filesystem.write(filepath, result)
|
.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 {
|
} catch (error) {
|
||||||
toast.show({ message: "Failed to export session", variant: "error" })
|
toast.show({ message: `Failed to export session: ${errorMessage(error)}`, variant: "error" })
|
||||||
}
|
}
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
|
|
@ -1744,6 +1741,20 @@ type ToolProps<T> = {
|
||||||
output?: string
|
output?: string
|
||||||
part: ToolPart
|
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>) {
|
function GenericTool(props: ToolProps<any>) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
|
|
@ -1962,6 +1973,7 @@ function BlockTool(props: {
|
||||||
title: string
|
title: string
|
||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
|
onTitleClick?: () => void
|
||||||
part?: ToolPart
|
part?: ToolPart
|
||||||
spinner?: boolean
|
spinner?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -1991,7 +2003,16 @@ function BlockTool(props: {
|
||||||
<Show
|
<Show
|
||||||
when={props.spinner}
|
when={props.spinner}
|
||||||
fallback={
|
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}
|
{props.title}
|
||||||
</text>
|
</text>
|
||||||
}
|
}
|
||||||
|
|
@ -2067,6 +2088,8 @@ function Shell(props: ToolProps<typeof ShellTool>) {
|
||||||
function Write(props: ToolProps<typeof WriteTool>) {
|
function Write(props: ToolProps<typeof WriteTool>) {
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
const pathFormatter = usePathFormatter()
|
const pathFormatter = usePathFormatter()
|
||||||
|
const openFile = useOpenFile()
|
||||||
|
const filePath = createMemo(() => props.metadata.filepath ?? props.input.filePath)
|
||||||
const code = createMemo(() => {
|
const code = createMemo(() => {
|
||||||
if (!props.input.content) return ""
|
if (!props.input.content) return ""
|
||||||
return props.input.content
|
return props.input.content
|
||||||
|
|
@ -2075,7 +2098,11 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.metadata.diagnostics !== undefined}>
|
<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}>
|
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
|
||||||
<code
|
<code
|
||||||
conceal={false}
|
conceal={false}
|
||||||
|
|
@ -2089,7 +2116,13 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
||||||
</BlockTool>
|
</BlockTool>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<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)}
|
Write {pathFormatter.format(props.input.filePath)}
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
@ -2287,6 +2320,8 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
const pathFormatter = usePathFormatter()
|
const pathFormatter = usePathFormatter()
|
||||||
|
const openFile = useOpenFile()
|
||||||
|
const filePath = createMemo(() => props.metadata.filediff?.file ?? props.input.filePath)
|
||||||
|
|
||||||
const view = createMemo(() => {
|
const view = createMemo(() => {
|
||||||
const diffStyle = ctx.tui.diff_style
|
const diffStyle = ctx.tui.diff_style
|
||||||
|
|
@ -2302,7 +2337,11 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.metadata.diff !== undefined}>
|
<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}>
|
<box paddingLeft={1}>
|
||||||
<diff
|
<diff
|
||||||
diff={diffContent()}
|
diff={diffContent()}
|
||||||
|
|
@ -2328,7 +2367,13 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||||
</BlockTool>
|
</BlockTool>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<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 })}
|
Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })}
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
@ -2340,6 +2385,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
const pathFormatter = usePathFormatter()
|
const pathFormatter = usePathFormatter()
|
||||||
|
const openFile = useOpenFile()
|
||||||
|
|
||||||
const files = createMemo(() => props.metadata.files ?? [])
|
const files = createMemo(() => props.metadata.files ?? [])
|
||||||
|
|
||||||
|
|
@ -2387,7 +2433,15 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||||
<Match when={files().length > 0}>
|
<Match when={files().length > 0}>
|
||||||
<For each={files()}>
|
<For each={files()}>
|
||||||
{(file) => (
|
{(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
|
<Show
|
||||||
when={file.type !== "delete"}
|
when={file.type !== "delete"}
|
||||||
fallback={
|
fallback={
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,74 @@
|
||||||
import { defer } from "@/util/defer"
|
import { defer } from "@/util/defer"
|
||||||
import { rm } from "node:fs/promises"
|
import { mkdtemp, rm } from "node:fs/promises"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { CliRenderer } from "@opentui/core"
|
import type { CliRenderer } from "@opentui/core"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Process } from "@/util/process"
|
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> {
|
export async function open(opts: { value: string; renderer: CliRenderer; cwd: string }): Promise<string | undefined> {
|
||||||
const editor = process.env["VISUAL"] || process.env["EDITOR"]
|
const editor = configuredEditor()
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
||||||
const filepath = join(tmpdir(), `${Date.now()}.md`)
|
const draft = await createDraft(opts.value)
|
||||||
await using _ = defer(async () => rm(filepath, { force: true }))
|
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.suspend()
|
||||||
opts.renderer.currentRenderBuffer.clear()
|
opts.renderer.currentRenderBuffer.clear()
|
||||||
try {
|
try {
|
||||||
const parts = editor.split(" ")
|
const parts = editor.split(/\s+/)
|
||||||
const proc = Process.spawn([...parts, filepath], {
|
const proc = Process.spawn([...parts, filepath], {
|
||||||
cwd: opts.cwd,
|
cwd: opts.cwd,
|
||||||
stdin: "inherit",
|
stdin: "inherit",
|
||||||
|
|
@ -25,9 +76,8 @@ export async function open(opts: { value: string; renderer: CliRenderer; cwd?: s
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
shell: process.platform === "win32",
|
shell: process.platform === "win32",
|
||||||
})
|
})
|
||||||
await proc.exited
|
const code = await proc.exited
|
||||||
const content = await Filesystem.readText(filepath)
|
if (code !== 0) throw new Error(`Editor exited with code ${code}`)
|
||||||
return content || undefined
|
|
||||||
} finally {
|
} finally {
|
||||||
opts.renderer.currentRenderBuffer.clear()
|
opts.renderer.currentRenderBuffer.clear()
|
||||||
opts.renderer.resume()
|
opts.renderer.resume()
|
||||||
|
|
|
||||||
203
packages/opencode/test/cli/tui/editor.test.ts
Normal file
203
packages/opencode/test/cli/tui/editor.test.ts
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue