@pi-archimedes/image-paste 2.10.4 → 2.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/package.json +4 -4
- package/src/clipboard.test.ts +497 -3
- package/src/clipboard.ts +311 -4
- package/src/default-export.test.ts +90 -2
- package/src/index.ts +176 -35
- package/src/keybinding-offer.test.ts +1671 -10
- package/src/keybinding-offer.ts +741 -38
- package/src/paste-dispatch.test.ts +516 -0
- package/src/paste-mutex.test.ts +165 -0
- package/src/shortcut-gate.test.ts +220 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ── The shortcut gate: `archimedes.imagePaste.suppressNativePaste: false` ────
|
|
4
|
+
//
|
|
5
|
+
// ADR 0030's amendment promises that turning the flag off "hands the key back to
|
|
6
|
+
// pi". Removing our `app.clipboard.pasteImage` unbinding from
|
|
7
|
+
// ~/.pi/agent/keybindings.json is only half of that: pi 1.2.0 keeps an extension
|
|
8
|
+
// shortcut that collides with a NON-reserved built-in (`app.clipboard.pasteImage`
|
|
9
|
+
// is absent from `RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS`, so
|
|
10
|
+
// `restrictOverride === false` and `ExtensionRunner.getShortcuts` prints
|
|
11
|
+
// "… Using <ext>" and keeps ours — `dist/core/extensions/runner.js:467-474`), and
|
|
12
|
+
// `CustomEditor.handleInput` consults `onExtensionShortcut` BEFORE its
|
|
13
|
+
// `app.clipboard.pasteImage` branch and returns (`dist/modes/interactive/
|
|
14
|
+
// components/custom-editor.js:63-70`). So while the shortcut is registered the
|
|
15
|
+
// restored native binding is unreachable and the flag buys nothing but a shortcut
|
|
16
|
+
// conflict line in `[Extension issues]`. The other half of the promise is
|
|
17
|
+
// therefore ours: with the flag off, `registerImagePaste` must not claim the key.
|
|
18
|
+
//
|
|
19
|
+
// The mock replaces the whole pi module (vitest requires every used export):
|
|
20
|
+
// `getAgentDir` for the settings read, `convertToPng` for clipboard.ts.
|
|
21
|
+
// `core/settings-io` captures `join(getAgentDir(), "settings.json")` at module
|
|
22
|
+
// load (settings-io.ts:6), hence one scratch dir for the whole file, rewritten
|
|
23
|
+
// per case.
|
|
24
|
+
|
|
25
|
+
const { tempDir, fs, join } = vi.hoisted(() => {
|
|
26
|
+
const fs = require("node:fs");
|
|
27
|
+
const { join } = require("node:path");
|
|
28
|
+
const { tmpdir } = require("node:os");
|
|
29
|
+
const { randomUUID } = require("node:crypto");
|
|
30
|
+
const dir = join(tmpdir(), `image-paste-shortcut-gate-${randomUUID()}`);
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
32
|
+
return { tempDir: dir, fs, join };
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
36
|
+
getAgentDir: () => tempDir,
|
|
37
|
+
convertToPng: () => undefined,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const { registerImagePaste } = await import("./index.js");
|
|
41
|
+
const defaultExport = (await import("./index.js")).default;
|
|
42
|
+
|
|
43
|
+
// ── fixtures ────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const NAMESPACE = "archimedes.imagePaste";
|
|
46
|
+
|
|
47
|
+
/** `getImagePasteShortcuts()` as it stands today, per platform. */
|
|
48
|
+
const ALL_PASTE_SHORTCUTS: string[] =
|
|
49
|
+
process.platform === "win32" ? ["alt+v", "ctrl+alt+v"] : ["ctrl+v", "alt+v", "ctrl+alt+v"];
|
|
50
|
+
|
|
51
|
+
const settingsPath = (): string => join(tempDir, "settings.json");
|
|
52
|
+
|
|
53
|
+
function writeSettings(imagePaste: Record<string, unknown> | undefined): void {
|
|
54
|
+
const settings: Record<string, unknown> = {};
|
|
55
|
+
if (imagePaste !== undefined) settings[NAMESPACE] = imagePaste;
|
|
56
|
+
fs.writeFileSync(settingsPath(), JSON.stringify(settings), "utf-8");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface FakePi {
|
|
60
|
+
on: ReturnType<typeof vi.fn>;
|
|
61
|
+
registerShortcut: ReturnType<typeof vi.fn>;
|
|
62
|
+
registerMessageRenderer: ReturnType<typeof vi.fn>;
|
|
63
|
+
registerCommand: ReturnType<typeof vi.fn>;
|
|
64
|
+
sendMessage: ReturnType<typeof vi.fn>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fakePi(): FakePi {
|
|
68
|
+
return {
|
|
69
|
+
on: vi.fn(),
|
|
70
|
+
registerShortcut: vi.fn(),
|
|
71
|
+
registerMessageRenderer: vi.fn(),
|
|
72
|
+
registerCommand: vi.fn(),
|
|
73
|
+
sendMessage: vi.fn(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Every key `registerImagePaste` handed to `pi.registerShortcut`. */
|
|
78
|
+
function registeredKeys(pi: FakePi): string[] {
|
|
79
|
+
return pi.registerShortcut.mock.calls.map((call) => String(call[0]));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
// No settings file at all is the "flag absent" default.
|
|
84
|
+
try {
|
|
85
|
+
fs.unlinkSync(settingsPath());
|
|
86
|
+
} catch {
|
|
87
|
+
// absent
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── the gate ────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
describe("image-paste shortcut registration is gated on suppressNativePaste", () => {
|
|
94
|
+
it("registers NO shortcut at all when suppressNativePaste is false (the key goes back to pi)", () => {
|
|
95
|
+
writeSettings({ suppressNativePaste: false });
|
|
96
|
+
const pi = fakePi();
|
|
97
|
+
|
|
98
|
+
registerImagePaste(pi as never);
|
|
99
|
+
|
|
100
|
+
expect(registeredKeys(pi)).toEqual([]);
|
|
101
|
+
expect(pi.registerShortcut).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("registers every paste shortcut when the setting is absent (today's behaviour, unchanged)", () => {
|
|
105
|
+
const pi = fakePi();
|
|
106
|
+
|
|
107
|
+
registerImagePaste(pi as never);
|
|
108
|
+
|
|
109
|
+
expect(registeredKeys(pi).sort()).toEqual([...ALL_PASTE_SHORTCUTS].sort());
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("registers every paste shortcut when suppressNativePaste is true", () => {
|
|
113
|
+
writeSettings({ suppressNativePaste: true });
|
|
114
|
+
const pi = fakePi();
|
|
115
|
+
|
|
116
|
+
registerImagePaste(pi as never);
|
|
117
|
+
|
|
118
|
+
expect(registeredKeys(pi).sort()).toEqual([...ALL_PASTE_SHORTCUTS].sort());
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("treats a value that is not the boolean false as today's behaviour", () => {
|
|
122
|
+
// Same strict-comparison rule as the restore: `"false"` and `0` are not
|
|
123
|
+
// `false`, so they must not silently free the key.
|
|
124
|
+
for (const value of ["false", 0, null, "", "no", {}]) {
|
|
125
|
+
fs.rmSync(settingsPath(), { force: true });
|
|
126
|
+
writeSettings({ suppressNativePaste: value });
|
|
127
|
+
const pi = fakePi();
|
|
128
|
+
|
|
129
|
+
registerImagePaste(pi as never);
|
|
130
|
+
|
|
131
|
+
expect(registeredKeys(pi).sort(), `suppressNativePaste=${JSON.stringify(value)}`).toEqual(
|
|
132
|
+
[...ALL_PASTE_SHORTCUTS].sort(),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("re-reads the setting on EVERY registration (per-session gate, never a module-level flag)", () => {
|
|
138
|
+
// pi caches the extension FACTORY (`extensionCache`, `dist/core/extensions/
|
|
139
|
+
// loader.js:461-489`) but re-invokes it for each session, and the composed
|
|
140
|
+
// suite calls `registerImagePaste` from its own `session_start` handler. So
|
|
141
|
+
// the decision must be taken inside the function: a value computed once at
|
|
142
|
+
// module scope (the `#106`-era bug) makes every later session inherit the
|
|
143
|
+
// first session's answer. Each call below stands for one session start.
|
|
144
|
+
writeSettings({ suppressNativePaste: false });
|
|
145
|
+
const off = fakePi();
|
|
146
|
+
registerImagePaste(off as never);
|
|
147
|
+
expect(registeredKeys(off)).toEqual([]);
|
|
148
|
+
|
|
149
|
+
writeSettings({ suppressNativePaste: true });
|
|
150
|
+
const on = fakePi();
|
|
151
|
+
registerImagePaste(on as never);
|
|
152
|
+
expect(registeredKeys(on).sort(), "a later session must get the shortcuts back").toEqual(
|
|
153
|
+
[...ALL_PASTE_SHORTCUTS].sort(),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
writeSettings({ suppressNativePaste: false });
|
|
157
|
+
const offAgain = fakePi();
|
|
158
|
+
registerImagePaste(offAgain as never);
|
|
159
|
+
expect(registeredKeys(offAgain), "and must give them up again").toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("leaves no other route from a key or command to the paste path when the flag is off", () => {
|
|
163
|
+
// "No half-states": nothing may still call our paste path on the freed key.
|
|
164
|
+
// The only entry point the package has is `pi.registerShortcut`, so the
|
|
165
|
+
// fence is (a) no shortcut for any of the paste keys and (b) no command
|
|
166
|
+
// alias registered at all.
|
|
167
|
+
writeSettings({ suppressNativePaste: false });
|
|
168
|
+
const pi = fakePi();
|
|
169
|
+
|
|
170
|
+
registerImagePaste(pi as never);
|
|
171
|
+
|
|
172
|
+
for (const key of ALL_PASTE_SHORTCUTS) {
|
|
173
|
+
const clash = pi.registerShortcut.mock.calls.filter((call) => call[0] === key);
|
|
174
|
+
expect(clash, `ctrl+v-class key ${key} still registered`).toEqual([]);
|
|
175
|
+
}
|
|
176
|
+
expect(pi.registerCommand).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("keeps the rest of the feature registered while the key is handed back", () => {
|
|
180
|
+
// The goal is the key, not deleting the feature: the submit-time marker
|
|
181
|
+
// attachment and the preview renderer stay wired, and the per-session queue
|
|
182
|
+
// lifecycle keeps running (no orphaned state left pointing at a key that no
|
|
183
|
+
// longer exists).
|
|
184
|
+
writeSettings({ suppressNativePaste: false });
|
|
185
|
+
const pi = fakePi();
|
|
186
|
+
|
|
187
|
+
registerImagePaste(pi as never);
|
|
188
|
+
|
|
189
|
+
const events = pi.on.mock.calls.map((call) => String(call[0]));
|
|
190
|
+
expect(events).toContain("input");
|
|
191
|
+
expect(pi.registerMessageRenderer).toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("image-paste standalone factory — same gate, same call site", () => {
|
|
196
|
+
it("registers no shortcut from the default export when suppressNativePaste is false", () => {
|
|
197
|
+
// A standalone install goes through the default export, which calls
|
|
198
|
+
// `registerImagePaste`; gating only meta's call site would leave this path
|
|
199
|
+
// claiming the key.
|
|
200
|
+
writeSettings({ suppressNativePaste: false });
|
|
201
|
+
const pi = fakePi();
|
|
202
|
+
|
|
203
|
+
defaultExport(pi as never);
|
|
204
|
+
|
|
205
|
+
expect(pi.registerShortcut).not.toHaveBeenCalled();
|
|
206
|
+
expect(pi.registerMessageRenderer).toHaveBeenCalled();
|
|
207
|
+
const events = pi.on.mock.calls.map((call) => String(call[0]));
|
|
208
|
+
expect(events).toEqual(
|
|
209
|
+
expect.arrayContaining(["input", "session_start", "session_shutdown"]),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("registers the shortcuts from the default export when the flag is absent", () => {
|
|
214
|
+
const pi = fakePi();
|
|
215
|
+
|
|
216
|
+
defaultExport(pi as never);
|
|
217
|
+
|
|
218
|
+
expect(registeredKeys(pi).sort()).toEqual([...ALL_PASTE_SHORTCUTS].sort());
|
|
219
|
+
});
|
|
220
|
+
});
|