Skip to content

Commit 7beef42

Browse files
committed
Unlink Corbits-created files when idle Ctrl+C clears attachments
Clipboard ingest now assigns ephemeralPath instead of deleting the temp file immediately, so clear unlinks only Corbits-created files and leaves operator-owned path mentions untouched.
1 parent 28baead commit 7beef42

4 files changed

Lines changed: 92 additions & 17 deletions

File tree

src/tui/image-attachments.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ export function findImagePathMentions(text: string, cwd: string): ImagePathMenti
9090
return mentions;
9191
}
9292

93-
export async function imageAttachmentFromPath(path: string): Promise<AttachImageResult> {
93+
export async function imageAttachmentFromPath(
94+
path: string,
95+
opts?: { ephemeral?: boolean },
96+
): Promise<AttachImageResult> {
9497
const mimeType = imageMimeTypeForPath(path);
9598
if (mimeType === undefined) return { ok: false, reason: "unsupported image type" };
9699
let info;
@@ -112,6 +115,7 @@ export async function imageAttachmentFromPath(path: string): Promise<AttachImage
112115
// downscaling recompresses them differently.
113116
const contentHash = await hashImageBytes(raw);
114117
const capped = await capImageForIngestion(raw, mimeType);
118+
const ephemeral = opts?.ephemeral === true;
115119
return {
116120
ok: true,
117121
attachment: {
@@ -122,7 +126,7 @@ export async function imageAttachmentFromPath(path: string): Promise<AttachImage
122126
: replaceExtension(basename(path), capped.contentType),
123127
contentType: capped.contentType,
124128
data: capped.data,
125-
path,
129+
...(ephemeral ? { ephemeralPath: path } : { path }),
126130
contentHash,
127131
},
128132
};
@@ -228,14 +232,15 @@ end try
228232
return { ok: false, reason: "no PNG image found on the clipboard" };
229233
}
230234

231-
const attachment = await imageAttachmentFromPath(tmpPath);
232-
await unlink(tmpPath).catch(() => undefined);
233-
if (!attachment.ok) return attachment;
234-
const { path: _path, ...clipboardAttachment } = attachment.attachment;
235+
const attachment = await imageAttachmentFromPath(tmpPath, { ephemeral: true });
236+
if (!attachment.ok) {
237+
await unlink(tmpPath).catch(() => undefined);
238+
return attachment;
239+
}
235240
return {
236241
ok: true,
237242
attachment: {
238-
...clipboardAttachment,
243+
...attachment.attachment,
239244
name: `clipboard-${new Date().toISOString().replace(/[:.]/g, "-")}.png`,
240245
},
241246
};

src/tui/prompt-features.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
* suggestions.
55
*/
66
import { describe, expect, test } from "bun:test";
7+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8+
import { tmpdir } from "node:os";
9+
import { join } from "node:path";
710

811
import type { PendingImageAttachment } from "./image-attachments.js";
912
import { withTestRenderer, type Harness } from "./harness";
@@ -222,6 +225,35 @@ describe("image attachments", () => {
222225
});
223226
});
224227

228+
test("a duplicate clipboard paste unlinks only the rejected ephemeral file", async () => {
229+
await withShell(async (shell) => {
230+
const dir = mkdtempSync(join(tmpdir(), "clip-dup-"));
231+
const kept = join(dir, "kept.png");
232+
const rejected = join(dir, "rejected.png");
233+
writeFileSync(kept, "kept-bytes");
234+
writeFileSync(rejected, "rejected-bytes");
235+
try {
236+
let calls = 0;
237+
setPromptImageSource(shell, async () => {
238+
calls += 1;
239+
return {
240+
ok: true,
241+
attachment: {
242+
...(calls === 1 ? CLIP : CLIP_SAME_CONTENT),
243+
ephemeralPath: calls === 1 ? kept : rejected,
244+
},
245+
};
246+
});
247+
expect(await attachClipboardImage(shell)).toBe(true);
248+
expect(await attachClipboardImage(shell)).toBe(false);
249+
expect(existsSync(kept)).toBe(true);
250+
expect(existsSync(rejected)).toBe(false);
251+
} finally {
252+
rmSync(dir, { recursive: true, force: true });
253+
}
254+
});
255+
});
256+
225257
test("a genuinely different image still attaches alongside the first", async () => {
226258
await withShell(async (shell) => {
227259
let calls = 0;

src/tui/prompt-slash-exit.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { join } from "node:path";
99

1010
import { withTestRenderer } from "./harness";
1111
import type { PaletteCommand } from "./command-catalog";
12-
import type { PendingImageAttachment } from "./image-attachments.js";
12+
import { imageAttachmentFromPath, type PendingImageAttachment } from "./image-attachments.js";
1313
import {
1414
CTRL_C_EXIT_WINDOW_MS,
1515
addPendingAttachment,
@@ -386,6 +386,38 @@ describe("Ctrl+C exit", () => {
386386
}
387387
});
388388
});
389+
390+
test("ingesting a Corbits-written file assigns ephemeralPath so idle Ctrl+C unlinks only that file", async () => {
391+
await withShell(async ({ shell }) => {
392+
const dir = mkdtempSync(join(tmpdir(), "ctrlc-attach-ingest-"));
393+
const ephemeral = join(dir, "clip.png");
394+
const operator = join(dir, "shot.png");
395+
writeFileSync(ephemeral, "ephemeral-bytes");
396+
writeFileSync(operator, "operator-bytes");
397+
try {
398+
const clip = await imageAttachmentFromPath(ephemeral, { ephemeral: true });
399+
const mention = await imageAttachmentFromPath(operator);
400+
expect(clip.ok).toBe(true);
401+
expect(mention.ok).toBe(true);
402+
if (!clip.ok || !mention.ok) return;
403+
404+
expect(clip.attachment.ephemeralPath).toBe(ephemeral);
405+
expect(clip.attachment.path).toBeUndefined();
406+
expect(mention.attachment.path).toBe(operator);
407+
expect(mention.attachment.ephemeralPath).toBeUndefined();
408+
409+
addPendingAttachment(shell, clip.attachment);
410+
addPendingAttachment(shell, mention.attachment);
411+
handleCtrlC(shell, 0);
412+
413+
expect(shell.pendingAttachments).toHaveLength(0);
414+
expect(existsSync(ephemeral)).toBe(false);
415+
expect(existsSync(operator)).toBe(true);
416+
} finally {
417+
rmSync(dir, { recursive: true, force: true });
418+
}
419+
});
420+
});
389421
});
390422

391423
function pendingImage(id: string, extra?: Partial<PendingImageAttachment>): PendingImageAttachment {

src/tui/shell.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -973,14 +973,16 @@ export function clearPendingAttachments(shell: AppShell): void {
973973
const pending = shell.pendingAttachments;
974974
shell.pendingAttachments = [];
975975
paintChrome(shell);
976-
for (const attachment of pending) {
977-
const ephemeral = attachment.ephemeralPath;
978-
if (ephemeral === undefined) continue;
979-
try {
980-
unlinkSync(ephemeral);
981-
} catch (err) {
982-
if (!isENOENT(err)) throw err;
983-
}
976+
for (const attachment of pending) unlinkEphemeral(attachment);
977+
}
978+
979+
function unlinkEphemeral(attachment: PendingImageAttachment): void {
980+
const ephemeral = attachment.ephemeralPath;
981+
if (ephemeral === undefined) return;
982+
try {
983+
unlinkSync(ephemeral);
984+
} catch (err) {
985+
if (!isENOENT(err)) throw err;
984986
}
985987
}
986988

@@ -999,7 +1001,10 @@ export async function attachClipboardImage(shell: AppShell): Promise<boolean> {
9991001
const result = await source();
10001002
// Quitting while the clipboard read is pending tears down the shell's
10011003
// renderables; a stale continuation must not mutate them on resume.
1002-
if (shell.disposed) return false;
1004+
if (shell.disposed) {
1005+
if (result.ok) unlinkEphemeral(result.attachment);
1006+
return false;
1007+
}
10031008
if (!result.ok) {
10041009
setStatusFlash(shell, `image attach failed: ${result.reason}`, {
10051010
ttlMs: RUNTIME_FLASH_MS,
@@ -1008,6 +1013,7 @@ export async function attachClipboardImage(shell: AppShell): Promise<boolean> {
10081013
}
10091014
const duplicate = findDuplicateAttachment(shell.pendingAttachments, result.attachment);
10101015
if (duplicate !== undefined) {
1016+
unlinkEphemeral(result.attachment);
10111017
setStatusFlash(shell, `${duplicate.name} is already attached`, {
10121018
ttlMs: RUNTIME_FLASH_MS,
10131019
});

0 commit comments

Comments
 (0)