Skip to content

Commit 81fc803

Browse files
feat: allow editing loaded plans
1 parent b01131a commit 81fc803

3 files changed

Lines changed: 211 additions & 10 deletions

File tree

‎src/tui.tsx‎

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { PluginConfig } from './types'
1414
import { ExecutePlanPanel } from './tui/execute-plan-panel'
1515
import { connectForgeProject, type ForgeProjectClient } from './utils/tui-client'
1616
import { savePlanToArchive, listArchivedPlans, readArchivedPlan, resolvePlanArchiveDir, hashPlanContent, DEFAULT_PLAN_ARCHIVE_TTL_MS, type ArchivedPlan } from './utils/plan-archive'
17+
import { savePlanFromDialog } from './utils/plan-save-target'
1718

1819
type TuiKeybinds = {
1920
viewPlan: string
@@ -103,15 +104,61 @@ function PlanViewerDialog(props: {
103104

104105
const handleSave = async () => {
105106
const text = textareaRef?.plainText ?? content()
106-
const saved = await props.client.plan.write(props.sessionId, text)
107-
props.api.ui.toast({
108-
message: saved ? 'Plan saved' : 'Failed to save plan',
109-
variant: saved ? 'success' : 'error',
110-
duration: 3000,
107+
const ttlMs = props.pluginConfig.tui?.planArchiveTtlMs ?? DEFAULT_PLAN_ARCHIVE_TTL_MS
108+
const outcome = await savePlanFromDialog({
109+
sessionId: props.sessionId || undefined,
110+
projectId: props.client.projectId,
111+
text,
112+
ttlMs,
113+
writeSession: (sid, value) => props.client.plan.write(sid, value),
111114
})
112-
if (saved) {
115+
116+
if (outcome.kind === 'session') {
117+
props.api.ui.toast({
118+
message: outcome.ok ? 'Plan saved' : 'Failed to save plan',
119+
variant: outcome.ok ? 'success' : 'error',
120+
duration: 3000,
121+
})
122+
if (outcome.ok) {
123+
setContent(text)
124+
setEditing(false)
125+
}
126+
return
127+
}
128+
129+
if (outcome.kind === 'archive' && outcome.ok) {
130+
props.api.ui.toast({
131+
message: outcome.deduped ? 'Plan unchanged (already archived)' : 'Plan archived',
132+
variant: 'success',
133+
duration: 3000,
134+
})
113135
setContent(text)
114136
setEditing(false)
137+
void props.onRefresh?.()
138+
return
139+
}
140+
141+
if (outcome.kind === 'archive' && !outcome.ok) {
142+
props.api.ui.toast({
143+
message: `Failed to archive plan: ${outcome.error.message}`,
144+
variant: 'error',
145+
duration: 4000,
146+
})
147+
return
148+
}
149+
150+
props.api.ui.toast({
151+
message: 'No session or project context to save into',
152+
variant: 'warning',
153+
duration: 4000,
154+
})
155+
}
156+
157+
const flushTextareaToContent = () => {
158+
if (!editing()) return
159+
const text = textareaRef?.plainText
160+
if (typeof text === 'string' && text !== content()) {
161+
setContent(text)
115162
}
116163
}
117164

@@ -162,10 +209,10 @@ function PlanViewerDialog(props: {
162209
onSelect={(_, option) => {
163210
if (!option) return
164211
switch (option.value) {
165-
case 'view': setEditing(false); setExecuting(false); break
212+
case 'view': flushTextareaToContent(); setEditing(false); setExecuting(false); break
166213
case 'edit': setEditing(true); setExecuting(false); break
167-
case 'execute': setEditing(false); setExecuting(true); break
168-
case 'export': handleExport(); break
214+
case 'execute': flushTextareaToContent(); setEditing(false); setExecuting(true); break
215+
case 'export': flushTextareaToContent(); handleExport(); break
169216
}
170217
}}
171218
showUnderline={false}
@@ -293,7 +340,6 @@ function LoadPlanDialog(props: {
293340
pluginConfig={props.pluginConfig}
294341
planContent={content}
295342
sessionId={props.sessionId ?? ''}
296-
startInExecuteMode={true}
297343
onRefresh={props.onRefresh}
298344
/>
299345
))

‎src/utils/plan-save-target.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { savePlanToArchive, DEFAULT_PLAN_ARCHIVE_TTL_MS } from './plan-archive'
2+
3+
export type PlanSaveOutcome =
4+
| { kind: 'session'; ok: boolean }
5+
| { kind: 'archive'; ok: true; deduped: boolean; filepath: string; filename: string }
6+
| { kind: 'archive'; ok: false; error: Error }
7+
| { kind: 'noop'; reason: 'missing-project' }
8+
9+
export interface SavePlanFromDialogArgs {
10+
sessionId: string | undefined
11+
projectId: string | undefined
12+
text: string
13+
ttlMs?: number
14+
writeSession: (sessionId: string, text: string) => Promise<boolean>
15+
now?: Date
16+
}
17+
18+
export async function savePlanFromDialog(args: SavePlanFromDialogArgs): Promise<PlanSaveOutcome> {
19+
const { sessionId, projectId, text, writeSession } = args
20+
if (sessionId) {
21+
const ok = await writeSession(sessionId, text)
22+
return { kind: 'session', ok }
23+
}
24+
if (!projectId) return { kind: 'noop', reason: 'missing-project' }
25+
try {
26+
const { deduped, filepath, filename } = savePlanToArchive(
27+
projectId,
28+
text,
29+
args.now ?? new Date(),
30+
args.ttlMs ?? DEFAULT_PLAN_ARCHIVE_TTL_MS,
31+
)
32+
return { kind: 'archive', ok: true, deduped, filepath, filename }
33+
} catch (err) {
34+
return { kind: 'archive', ok: false, error: err as Error }
35+
}
36+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
2+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
3+
import { join } from 'path'
4+
import { savePlanFromDialog } from '../../src/utils/plan-save-target'
5+
import { resolvePlanArchiveDir } from '../../src/utils/plan-archive'
6+
7+
const TEST_DIR = '/tmp/opencode-forge-plan-save-target-test-' + Date.now()
8+
9+
describe('savePlanFromDialog', () => {
10+
let testDataDir: string
11+
12+
beforeEach(() => {
13+
testDataDir = TEST_DIR + '-data-' + Math.random().toString(36).slice(2)
14+
process.env['XDG_DATA_HOME'] = testDataDir
15+
})
16+
17+
afterEach(() => {
18+
delete process.env['XDG_DATA_HOME']
19+
if (existsSync(testDataDir)) {
20+
rmSync(testDataDir, { recursive: true, force: true })
21+
}
22+
})
23+
24+
test('sessionId present writes to session and does not touch archive', async () => {
25+
const calls: Array<[string, string]> = []
26+
const outcome = await savePlanFromDialog({
27+
sessionId: 'session-1',
28+
projectId: 'project-1',
29+
text: '# Session Plan',
30+
writeSession: async (sessionId, text) => {
31+
calls.push([sessionId, text])
32+
return true
33+
},
34+
})
35+
36+
expect(calls).toEqual([['session-1', '# Session Plan']])
37+
expect(existsSync(resolvePlanArchiveDir('project-1'))).toBe(false)
38+
expect(outcome).toEqual({ kind: 'session', ok: true })
39+
})
40+
41+
test('sessionId present returns false when session write fails', async () => {
42+
const outcome = await savePlanFromDialog({
43+
sessionId: 'session-1',
44+
projectId: 'project-1',
45+
text: '# Session Plan',
46+
writeSession: async () => false,
47+
})
48+
49+
expect(outcome).toEqual({ kind: 'session', ok: false })
50+
})
51+
52+
test('sessionId empty archives and dedupes identical content', async () => {
53+
let sessionWrites = 0
54+
const args = {
55+
sessionId: undefined,
56+
projectId: 'project-archive',
57+
text: '# Archived Plan',
58+
writeSession: async () => {
59+
sessionWrites += 1
60+
return true
61+
},
62+
now: new Date('2026-05-04T12:00:00.000Z'),
63+
}
64+
65+
const first = await savePlanFromDialog(args)
66+
const second = await savePlanFromDialog(args)
67+
68+
expect(sessionWrites).toBe(0)
69+
if (first.kind === 'archive' && first.ok) {
70+
expect(first.deduped).toBe(false)
71+
expect(first.filename).toMatch(/^[0-9a-f]{64}\.md$/)
72+
expect(existsSync(first.filepath)).toBe(true)
73+
} else {
74+
throw new Error('expected archive success')
75+
}
76+
if (second.kind === 'archive' && second.ok) {
77+
expect(second.deduped).toBe(true)
78+
expect(existsSync(second.filepath)).toBe(true)
79+
} else {
80+
throw new Error('expected archive success')
81+
}
82+
})
83+
84+
test('sessionId empty and projectId missing returns noop and writes nothing', async () => {
85+
let sessionWrites = 0
86+
const outcome = await savePlanFromDialog({
87+
sessionId: '',
88+
projectId: undefined,
89+
text: '# No Context',
90+
writeSession: async () => {
91+
sessionWrites += 1
92+
return true
93+
},
94+
})
95+
96+
expect(sessionWrites).toBe(0)
97+
expect(outcome).toEqual({ kind: 'noop', reason: 'missing-project' })
98+
expect(existsSync(join(testDataDir, 'opencode', 'forge', 'plans'))).toBe(false)
99+
})
100+
101+
test('archive write errors are returned as archive failures', async () => {
102+
const sentinel = join(testDataDir, 'opencode')
103+
mkdirSync(testDataDir, { recursive: true })
104+
writeFileSync(sentinel, 'not a directory')
105+
106+
const outcome = await savePlanFromDialog({
107+
sessionId: undefined,
108+
projectId: 'project-error',
109+
text: '# Error Plan',
110+
writeSession: async () => true,
111+
})
112+
113+
if (outcome.kind === 'archive' && !outcome.ok) {
114+
expect(outcome.error).toBeInstanceOf(Error)
115+
} else {
116+
throw new Error('expected archive failure')
117+
}
118+
})
119+
})

0 commit comments

Comments
 (0)