mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(files): add save-as-new vs overwrite choice for library file edits (#564)
Editing a file from the library used to silently supersede it: the worker auto-saved every result as a new version and the leaf-only listing hid the original, which read as a destructive overwrite. Tool pages now show a per-edit choice for library-sourced files. The default saves the result as an independent new file and keeps the original; picking overwrite keeps the old superseding-version behavior. The client sends a saveMode multipart field next to fileId, validated with a 400 on unknown values, and autoSaveToLibrary branches on it. Every hand-written route that honors fileId parses the field the same way as the factory. The review panel shows where an auto-saved result went instead of offering a second, duplicate save. Tools whose route or submitter ignores fileId keep the selector hidden via a shared unsupported-tools set, and the choice resets to the non-destructive default whenever a new file is staged. Closes #495
This commit is contained in:
@@ -220,6 +220,7 @@ function createMockRequest(opts: {
|
||||
filename?: string;
|
||||
settings?: string;
|
||||
fileId?: string;
|
||||
saveMode?: string;
|
||||
clientJobId?: string;
|
||||
fileCount?: number;
|
||||
}) {
|
||||
@@ -260,6 +261,15 @@ function createMockRequest(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.saveMode) {
|
||||
parts.push({
|
||||
type: "field",
|
||||
fieldname: "saveMode",
|
||||
value: opts.saveMode,
|
||||
file: (async function* () {})(),
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.clientJobId) {
|
||||
parts.push({
|
||||
type: "field",
|
||||
@@ -410,6 +420,65 @@ describe("createToolRoute", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid saveMode field", async () => {
|
||||
const app = createMockApp();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
settings: JSON.stringify({}),
|
||||
fileId: "lib-1",
|
||||
saveMode: "bogus",
|
||||
});
|
||||
|
||||
await handler(req, reply);
|
||||
|
||||
expect(reply.status).toHaveBeenCalledWith(400);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.stringContaining("saveMode") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes a valid saveMode through to the enqueued job", async () => {
|
||||
const app = createMockApp();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
settings: JSON.stringify({}),
|
||||
fileId: "lib-1",
|
||||
saveMode: "overwrite",
|
||||
});
|
||||
|
||||
await handler(req, reply);
|
||||
|
||||
expect(vi.mocked(enqueueToolJob).mock.calls[0][0]).toMatchObject({
|
||||
fileId: "lib-1",
|
||||
saveMode: "overwrite",
|
||||
});
|
||||
});
|
||||
|
||||
it("enqueues no saveMode when the field is absent", async () => {
|
||||
const app = createMockApp();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
settings: JSON.stringify({}),
|
||||
fileId: "lib-1",
|
||||
});
|
||||
|
||||
await handler(req, reply);
|
||||
|
||||
expect(vi.mocked(enqueueToolJob).mock.calls[0][0].saveMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns 501 when AI feature bundle is not installed", async () => {
|
||||
vi.mocked(isToolInstalled).mockReturnValueOnce(false);
|
||||
const app = createMockApp();
|
||||
|
||||
Reference in New Issue
Block a user