diff --git a/README.md b/README.md index 4125d24b..061a9e89 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ A Visual Studio Code extension for [cht.sh](https://cht.sh/). - ${index} - the index of the snippet (e.g. 2 for the third answer) - `insertWithDoubleClick`: insert snippet with double click. - `showCopySuccessNotification`: Whether to show a notification after the snippet is copied to the clipboard. +- `saveBackups`: Whether to create backups of the snippets. ## Installation @@ -132,6 +133,41 @@ Saved snippets are displayed in IntelliSense ![Preview](https://raw.githubusercontent.com/mre/vscode-snippet/master/contrib/snippets-storage/search.gif) +## Restoring snippets from backups + +### Restoring with the built-in backup mechanism + +vscode-snippet creates backups of your snippets when you delete, rename, move or save snippets. These backups are stored **locally** on your computer. + +To restore a backup: + +1. Open the Snippets section +2. Click on the ![History icon](https://raw.githubusercontent.com/mre/vscode-snippet/master/contrib/snippets-storage/history.png) icon (alternatively, you can run the "Restore backups" command) +3. Select one of the backups from the list + +![Demo of restoring backups](https://raw.githubusercontent.com/mre/vscode-snippet/master/contrib/snippets-storage/restore-backups.gif) + +### Restoring with the VSCode settings sync + +If you have [VSCode settings sync](https://code.visualstudio.com/docs/editor/settings-sync) enabled, you can restore snippets by using VSCode's built-in backup mechanisms: [https://code.visualstudio.com/docs/editor/settings-sync#\_restoring-data](https://code.visualstudio.com/docs/editor/settings-sync#_restoring-data) + +## Exporting snippets + +VSCode stores snippets in the `state.vscdb` file in a `JSON` format. + +To export the snippets: + +1. Find the `state.vscdb` file + - On Ubuntu Linux: `~/.config/Code/User/globalStorage/state.vscdb` + - On Windows: `AppData\Roaming\Code\User\globalStorage\state.vscdb` + - On macOS: `~/Library/Application Support/Code/User/globalStorage/state.vscdb` +2. Inspect the content of this file using some tool that can open SQLite files, for example: [https://inloop.github.io/sqlite-viewer](https://inloop.github.io/sqlite-viewer) + 1. On this website, upload the `state.vscdb` file and run the following command: + ```sql + SELECT * FROM 'ItemTable' WHERE key like 'vscode-snippet.snippet' + ``` + ![SQLite Viewer](https://raw.githubusercontent.com/mre/vscode-snippet/master/contrib/snippets-storage/vscdb.png) 2. Then click "Execute". You should get a single row with the key `vscode-snippet.snippet` and a `JSON` value. This `JSON` contains all of your snippets. + ## Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md) diff --git a/assets/icons/history-dark.svg b/assets/icons/history-dark.svg new file mode 100644 index 00000000..d82fe00e --- /dev/null +++ b/assets/icons/history-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/history-light.svg b/assets/icons/history-light.svg new file mode 100644 index 00000000..0aded818 --- /dev/null +++ b/assets/icons/history-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/contrib/snippets-storage/history.png b/contrib/snippets-storage/history.png new file mode 100644 index 00000000..3b8af266 Binary files /dev/null and b/contrib/snippets-storage/history.png differ diff --git a/contrib/snippets-storage/restore-backups.gif b/contrib/snippets-storage/restore-backups.gif new file mode 100644 index 00000000..71b4587a Binary files /dev/null and b/contrib/snippets-storage/restore-backups.gif differ diff --git a/contrib/snippets-storage/vscdb.png b/contrib/snippets-storage/vscdb.png new file mode 100644 index 00000000..49e913c0 Binary files /dev/null and b/contrib/snippets-storage/vscdb.png differ diff --git a/package-lock.json b/package-lock.json index 2848f3e6..38203d91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "snippet", - "version": "1.1.5", + "version": "1.1.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "snippet", - "version": "1.1.5", + "version": "1.1.6", "license": "MIT", "dependencies": { "@vscode/vsce": "^2.24.0", diff --git a/package.json b/package.json index d2c2d36e..c3fa9929 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "snippet", "displayName": "Snippet", "description": "Insert a snippet from cht.sh for Python, JavaScript, Ruby, C#, Go, Rust (and any other language)", - "version": "1.1.5", + "version": "1.1.6", "publisher": "vscode-snippet", "engines": { "vscode": "^1.74.0" @@ -123,6 +123,15 @@ "light": "assets/icons/add-light.svg", "dark": "assets/icons/add-dark.svg" } + }, + { + "title": "Restore backups", + "command": "snippet.restoreBackups", + "category": "Snippet", + "icon": { + "light": "assets/icons/history-light.svg", + "dark": "assets/icons/history-dark.svg" + } } ], "configuration": { @@ -162,6 +171,11 @@ "type": "boolean", "default": true, "description": "Whether to show a notification after the snippet is copied to the clipboard." + }, + "snippet.saveBackups": { + "type": "boolean", + "default": true, + "description": "Whether to create backups of the snippets." } } }, @@ -171,6 +185,11 @@ "command": "snippet.createFolder", "when": "view == snippetsView", "group": "navigation" + }, + { + "command": "snippet.restoreBackups", + "when": "view == snippetsView", + "group": "navigation" } ], "view/item/context": [ diff --git a/src/backupManager.ts b/src/backupManager.ts new file mode 100644 index 00000000..69679525 --- /dev/null +++ b/src/backupManager.ts @@ -0,0 +1,119 @@ +import { randomUUID } from "crypto"; +import * as vscode from "vscode"; +import { getConfig } from "./config"; +import { formatUnixTime } from "./date"; +import SnippetsStorage, { + StorageOperation, + TreeElement, +} from "./snippetsStorage"; + +export interface Backup { + id: string; + dateUnix: number; + elements: TreeElement[]; + beforeOperation?: string; +} + +export interface BackupItem extends vscode.QuickPickItem { + item: Backup; +} + +const STORAGE_KEY = "snippet.snippetBackupsStorageKey"; +const MAX_BACKUPS = 10; + +export class BackupManager { + private backups: Backup[] = []; + private elementsBeforeRestore: TreeElement[] | null = null; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly snippets: SnippetsStorage + ) { + this.load(); + snippets.onBeforeSave = (elements, operation) => + this.makeBackup(elements, operation); + } + + getBackupItems(): BackupItem[] { + const items = this.backups.map((backup) => { + const time = `${formatUnixTime(backup.dateUnix)}`; + const detail = backup.beforeOperation + ? `before "${backup.beforeOperation}"` + : undefined; + const description = `${this.snippets.getSnippetCount( + backup.elements + )} snippet${ + this.snippets.getSnippetCount(backup.elements) === 1 ? "" : "s" + }`; + + return { + label: time, + item: backup, + description, + detail, + }; + }); + + items.sort((a, b) => b.item.dateUnix - a.item.dateUnix); + + return items; + } + + async restoreBackup(id: string) { + const backup = this.backups.find((backup) => backup.id === id); + + if (!backup) { + console.error(`Backup with id ${id} not found.`); + return; + } + + this.elementsBeforeRestore = this.snippets.getElements(); + await this.snippets.replaceElements(backup.elements); + } + + async undoLastRestore() { + if (this.elementsBeforeRestore === null) { + return; + } + + await this.snippets.replaceElements(this.elementsBeforeRestore); + this.elementsBeforeRestore = null; + } + + private load(): void { + this.backups = JSON.parse( + this.context.globalState.get(STORAGE_KEY) || "[]" + ) as Backup[]; + } + + private async makeBackup( + elements: TreeElement[], + operation?: StorageOperation + ) { + if (!getConfig("saveBackups")) { + return; + } + + const backup: Backup = { + id: randomUUID(), + dateUnix: Date.now(), + elements, + beforeOperation: operation, + }; + + this.backups.push(backup); + + if (this.backups.length > MAX_BACKUPS) { + this.backups.shift(); + } + + await this.save(); + } + + private async save() { + await this.context.globalState.update( + STORAGE_KEY, + JSON.stringify(this.backups) + ); + } +} diff --git a/src/config.ts b/src/config.ts index 38693467..2fe6e25e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,8 +17,6 @@ export async function pickLanguage() { const languages = await vscode.languages.getLanguages(); const disposables: Disposable[] = []; - // return await vscode.window.showQuickPick(languages); - try { return await new Promise((resolve) => { const input = vscode.window.createQuickPick(); diff --git a/src/date.ts b/src/date.ts new file mode 100644 index 00000000..9038f34c --- /dev/null +++ b/src/date.ts @@ -0,0 +1,7 @@ +export function formatUnixTime(ms: number): string { + const date = new Date(ms); + return `${date.toDateString()}, ${date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })}`; +} diff --git a/src/endpoints.ts b/src/endpoints.ts index aa304da0..9e9b4f6f 100644 --- a/src/endpoints.ts +++ b/src/endpoints.ts @@ -1,12 +1,14 @@ import * as vscode from "vscode"; +import { BackupManager } from "./backupManager"; import * as clipboard from "./clipboard"; -import { pickLanguage, getLanguage, getConfig } from "./config"; -import { query } from "./query"; +import { getConfig, getLanguage, pickLanguage } from "./config"; +import { formatUnixTime } from "./date"; +import languages from "./languages"; import { encodeRequest } from "./provider"; +import { query } from "./query"; import snippet from "./snippet"; -import { SnippetsTreeProvider, SnippetsTreeItem } from "./snippetsTreeProvider"; import SnippetsStorage from "./snippetsStorage"; -import languages from "./languages"; +import { SnippetsTreeItem, SnippetsTreeProvider } from "./snippetsTreeProvider"; export interface Request { language: string; @@ -383,3 +385,30 @@ export function createFolder(treeProvider: SnippetsTreeProvider) { await treeProvider.storage.createFolder(folderName, item?.id); }; } + +export function showBackups(backupManager: BackupManager) { + return async () => { + const backups = backupManager.getBackupItems(); + const selectedBackup = await vscode.window.showQuickPick(backups, { + placeHolder: + "Select a backup to restore. You will be able to undo this operation.", + title: "Select a backup", + }); + + if (!selectedBackup) { + return; + } + + await backupManager.restoreBackup(selectedBackup.item.id); + await vscode.commands.executeCommand("snippetsView.focus"); + const answer = await vscode.window.showInformationMessage( + `Restored backup from ${formatUnixTime(selectedBackup.item.dateUnix)}`, + "Ok", + "Undo" + ); + + if (answer === "Undo") { + await backupManager.undoLastRestore(); + } + }; +} diff --git a/src/extension.ts b/src/extension.ts index b30c116e..109a954d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,12 +1,13 @@ "use strict"; import * as vscode from "vscode"; +import { BackupManager } from "./backupManager"; import { cache } from "./cache"; -import SnippetProvider from "./provider"; +import { CompletionManager } from "./completionManager"; import * as endpoints from "./endpoints"; -import { SnippetsTreeProvider } from "./snippetsTreeProvider"; +import SnippetProvider from "./provider"; import SnippetsStorage from "./snippetsStorage"; -import { CompletionManager } from "./completionManager"; +import { SnippetsTreeProvider } from "./snippetsTreeProvider"; export function activate(ctx: vscode.ExtensionContext) { const snippetStorageKey = "snippet.snippetsStorageKey"; @@ -15,66 +16,114 @@ export function activate(ctx: vscode.ExtensionContext) { const snippetsStorage = new SnippetsStorage(ctx, snippetStorageKey); const snippetsTreeProvider = new SnippetsTreeProvider(ctx, snippetsStorage); new CompletionManager(ctx, snippetsStorage); + const backupManager = new BackupManager(ctx, snippetsStorage); - vscode.commands.registerCommand("snippet.find", () => - endpoints.findDefault(snippetsStorage) + ctx.subscriptions.push( + vscode.commands.registerCommand("snippet.find", () => + endpoints.findDefault(snippetsStorage) + ) ); - vscode.commands.registerCommand("snippet.findForLanguage", () => - endpoints.findForLanguage(snippetsStorage) + ctx.subscriptions.push( + vscode.commands.registerCommand("snippet.findForLanguage", () => + endpoints.findForLanguage(snippetsStorage) + ) ); - vscode.commands.registerCommand("snippet.findInplace", () => - endpoints.findInplace(snippetsStorage) + ctx.subscriptions.push( + vscode.commands.registerCommand("snippet.findInplace", () => + endpoints.findInplace(snippetsStorage) + ) ); - vscode.commands.registerCommand("snippet.findInNewEditor", () => - endpoints.findInNewEditor(snippetsStorage) + ctx.subscriptions.push( + vscode.commands.registerCommand("snippet.findInNewEditor", () => + endpoints.findInNewEditor(snippetsStorage) + ) ); - vscode.commands.registerCommand( - "snippet.findSelectedText", - endpoints.findSelectedText + ctx.subscriptions.push( + vscode.commands.registerCommand( + "snippet.findSelectedText", + endpoints.findSelectedText + ) ); - vscode.commands.registerCommand("snippet.showPreviousAnswer", () => - endpoints.showPreviousAnswer(snippetsStorage) + ctx.subscriptions.push( + vscode.commands.registerCommand("snippet.showPreviousAnswer", () => + endpoints.showPreviousAnswer(snippetsStorage) + ) ); - vscode.commands.registerCommand("snippet.showNextAnswer", () => - endpoints.showNextAnswer(snippetsStorage) + ctx.subscriptions.push( + vscode.commands.registerCommand("snippet.showNextAnswer", () => + endpoints.showNextAnswer(snippetsStorage) + ) ); - vscode.commands.registerCommand( - "snippet.toggleComments", - endpoints.toggleComments + ctx.subscriptions.push( + vscode.commands.registerCommand( + "snippet.toggleComments", + endpoints.toggleComments + ) ); - vscode.commands.registerCommand( - "snippet.saveSnippet", - endpoints.saveSnippet(snippetsTreeProvider) + ctx.subscriptions.push( + vscode.commands.registerCommand( + "snippet.saveSnippet", + endpoints.saveSnippet(snippetsTreeProvider) + ) + ); + ctx.subscriptions.push( + vscode.commands.registerCommand( + "snippet.insertSnippet", + endpoints.insertSnippet(snippetsTreeProvider) + ) ); - vscode.commands.registerCommand( - "snippet.insertSnippet", - endpoints.insertSnippet(snippetsTreeProvider) + ctx.subscriptions.push( + vscode.commands.registerCommand( + "snippet.deleteSnippet", + endpoints.deleteSnippet(snippetsTreeProvider) + ) ); - vscode.commands.registerCommand( - "snippet.deleteSnippet", - endpoints.deleteSnippet(snippetsTreeProvider) + ctx.subscriptions.push( + vscode.commands.registerCommand( + "snippet.renameSnippet", + endpoints.renameSnippet(snippetsTreeProvider) + ) ); - vscode.commands.registerCommand( - "snippet.renameSnippet", - endpoints.renameSnippet(snippetsTreeProvider) + ctx.subscriptions.push( + vscode.commands.registerCommand( + "snippet.copySnippet", + endpoints.copySnippet(snippetsTreeProvider) + ) ); - vscode.commands.registerCommand( - "snippet.copySnippet", - endpoints.copySnippet(snippetsTreeProvider) + ctx.subscriptions.push( + vscode.commands.registerCommand( + "snippet.findAndCopy", + endpoints.findAndCopy(snippetsStorage) + ) ); - vscode.commands.registerCommand( - "snippet.findAndCopy", - endpoints.findAndCopy(snippetsStorage) + ctx.subscriptions.push( + vscode.commands.registerCommand( + "snippet.createFolder", + endpoints.createFolder(snippetsTreeProvider) + ) ); - vscode.commands.registerCommand( - "snippet.createFolder", - endpoints.createFolder(snippetsTreeProvider) + ctx.subscriptions.push( + vscode.commands.registerCommand( + "snippet.restoreBackups", + endpoints.showBackups(backupManager) + ) ); + if (process.env.NODE_ENV === "test") { + // Expose the "moveElement" method for test purposes + ctx.subscriptions.push( + vscode.commands.registerCommand( + "snippet.test_moveElement", + async (sourceId, targetId) => + await snippetsStorage.moveElement(sourceId, targetId) + ) + ); + } + cache.state = ctx.globalState; const provider = new SnippetProvider(); - const disposableProvider = - vscode.workspace.registerTextDocumentContentProvider("snippet", provider); - ctx.subscriptions.push(disposableProvider); + ctx.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider("snippet", provider) + ); } diff --git a/src/snippetsStorage.ts b/src/snippetsStorage.ts index 6851d0d9..52167bfe 100644 --- a/src/snippetsStorage.ts +++ b/src/snippetsStorage.ts @@ -19,8 +19,21 @@ export interface FolderListItem extends vscode.QuickPickItem { label: string; } +export enum StorageOperation { + Save = "save snippet", + LoadDefault = "load default snippets", + Delete = "delete", + Rename = "rename", + CreateFolder = "create folder", + Move = "move", +} + export default class SnippetsStorage { public onSave?: () => void; + public onBeforeSave?: ( + elements: TreeElement[], + operation?: StorageOperation + ) => void; public onSnippetSave?: (snippet: TreeElement) => void; private readonly elements = new Map(); private rootId = ""; @@ -36,6 +49,10 @@ export default class SnippetsStorage { } } + getElements(): TreeElement[] { + return [...this.elements.values()]; + } + getFoldersList(): FolderListItem[] { const result: FolderListItem[] = []; @@ -58,7 +75,7 @@ export default class SnippetsStorage { for (const childId of parent.childIds) { const child = this.getElement(childId); - if (this.isFolder(child)) { + if (SnippetsStorage.isFolder(child)) { const joinedName = `${ current.label === "/" ? "/" : `${current.label}/` }${child.data.label}`; @@ -81,7 +98,7 @@ export default class SnippetsStorage { async deleteElement(id: string): Promise { const toDelete = this.getElement(id); - const messageForUser = this.isFolder(toDelete) + const messageForUser = SnippetsStorage.isFolder(toDelete) ? "Are you sure you want to delete this folder? Everything inside it will be deleted too." : "Are you sure you want to delete this snippet?"; @@ -103,18 +120,18 @@ export default class SnippetsStorage { 1 ); - await this.save(); + await this.save(StorageOperation.Delete); } async renameElement(id: string, newName: string): Promise { this.getElement(id).data.label = newName; - await this.save(); + await this.save(StorageOperation.Rename); } async createFolder(name: string, relativeToId?: string): Promise { const relativeToElement = this.getElement(relativeToId); - const parentId = this.isFolder(relativeToElement) + const parentId = SnippetsStorage.isFolder(relativeToElement) ? relativeToElement.data.id : relativeToElement.parentId; @@ -127,7 +144,7 @@ export default class SnippetsStorage { this.elements.set(folder.id, { childIds: [], data: folder, parentId }); this.getElement(parentId).childIds?.push(folder.id); - await this.save(); + await this.save(StorageOperation.CreateFolder); } async moveElement(sourceId: string, targetId?: string): Promise { @@ -138,7 +155,7 @@ export default class SnippetsStorage { const sourceElement = this.getElement(sourceId); const targetElement = this.getElement(targetId); - const newParentId = this.isFolder(targetElement) + const newParentId = SnippetsStorage.isFolder(targetElement) ? targetElement.data.id : targetElement.parentId; @@ -164,7 +181,7 @@ export default class SnippetsStorage { const newParentElement = this.getElement(newParentId); newParentElement.childIds?.push(sourceId); - await this.save(); + await this.save(StorageOperation.Move); } async saveSnippet( @@ -184,7 +201,7 @@ export default class SnippetsStorage { this.elements.set(data.id, element); this.getElement(parentId).childIds?.push(data.id); - await this.save(); + await this.save(StorageOperation.Save); this.onSnippetSave?.(element); } @@ -192,23 +209,37 @@ export default class SnippetsStorage { return this.getElement(id).data.content?.toString() || ""; } - async save(): Promise { + async save(operation?: StorageOperation): Promise { + const originalElements = JSON.parse( + this.context.globalState.get(this.storageKey) || "[]" + ) as TreeElement[]; + this.onBeforeSave?.(originalElements, operation); await this.context.globalState.update(this.storageKey, this.serialize()); this.onSave?.(); } - load(): void { - this.deserialize(this.context.globalState.get(this.storageKey) || "[]"); - } - *getSnippets(): IterableIterator { for (const element of this.elements.values()) { - if (!this.isFolder(element)) { + if (!SnippetsStorage.isFolder(element)) { yield element; } } } + getSnippetCount(elements: TreeElement[]) { + return elements.filter((x) => !SnippetsStorage.isFolder(x)).length; + } + + async replaceElements(newElements: TreeElement[]): Promise { + this.deserialize(JSON.stringify(newElements)); + await this.context.globalState.update(this.storageKey, this.serialize()); + this.onSave?.(); + } + + private load(): void { + this.deserialize(this.context.globalState.get(this.storageKey) || "[]"); + } + private async loadDefaultElements(): Promise { const root: TreeElementData = { id: randomUUID(), @@ -246,7 +277,7 @@ export default class SnippetsStorage { parentId: exampleFolder.id, }); - await this.save(); + await this.save(StorageOperation.LoadDefault); } private serialize(): string { @@ -266,7 +297,7 @@ export default class SnippetsStorage { }); } - private isFolder(element: TreeElement): boolean { + static isFolder(element: TreeElement): boolean { return element.childIds != null; } } diff --git a/src/test/suite/findSelectedText.test.ts b/src/test/suite/findSelectedText.test.ts index 802be822..68c3c5c1 100644 --- a/src/test/suite/findSelectedText.test.ts +++ b/src/test/suite/findSelectedText.test.ts @@ -1,6 +1,6 @@ import * as assert from "assert"; -import * as vscode from "vscode"; import { after, before } from "mocha"; +import * as vscode from "vscode"; import { closeAllEditors, getInitialDocument, diff --git a/src/test/suite/restoreBackups.test.ts b/src/test/suite/restoreBackups.test.ts new file mode 100644 index 00000000..587b8fb2 --- /dev/null +++ b/src/test/suite/restoreBackups.test.ts @@ -0,0 +1,188 @@ +import * as assert from "assert"; +import { afterEach } from "mocha"; +import * as sinon from "sinon"; +import * as vscode from "vscode"; +import { MessageItem } from "vscode"; +import { BackupItem } from "../../backupManager"; +import { cache } from "../../cache"; +import SnippetsStorage, { TreeElement } from "../../snippetsStorage"; +import { closeAllEditors, openDocumentAndSelectText } from "../testUtils"; + +suite("snippet.restoreBackups", () => { + afterEach(() => { + sinon.restore(); + }); + + test("No backups initially", async () => { + const backups = await getBackups(); + + assert.strictEqual(backups.length, 0); + }); + + test("Creates a backup after rename", async () => { + sinon.stub(vscode.window, "showInputBox").callsFake(() => { + return Promise.resolve("new name"); + }); + const originalElementsJson = getElementsJson(); + const snippet = getAnySnippet(originalElementsJson); + + await vscode.commands.executeCommand("snippet.renameSnippet", { + id: snippet.data.id, + }); + const backups = await getBackups(); + + assert.strictEqual(backups.length, 1); + assert.strictEqual( + JSON.stringify(backups[0].item.elements), + originalElementsJson + ); + }); + + test("Creates a backup after delete", async () => { + sinon.stub(vscode.window, "showInformationMessage").callsFake(() => { + return Promise.resolve("Yes" as unknown as MessageItem); + }); + const originalElementsJson = getElementsJson(); + const snippet = getAnySnippet(originalElementsJson); + + await vscode.commands.executeCommand("snippet.deleteSnippet", { + id: snippet.data.id, + }); + const backups = await getBackups(); + + assert.strictEqual(backups.length, 2); + assert.strictEqual( + JSON.stringify(backups[0].item.elements), + originalElementsJson + ); + }); + + test("Creates a backup after save", async () => { + await openDocumentAndSelectText({ + language: "javascript", + queryText: "query", + openInNewEditor: true, + }); + sinon.stub(vscode.window, "showQuickPick").callsFake((folders) => { + return folders[0]; + }); + sinon.stub(vscode.window, "showInputBox").callsFake(() => { + return Promise.resolve("new snippet"); + }); + const originalElementsJson = getElementsJson(); + + await vscode.commands.executeCommand("snippet.saveSnippet"); + sinon.restore(); + const backups = await getBackups(); + await closeAllEditors(); + + assert.strictEqual(backups.length, 3); + assert.strictEqual( + JSON.stringify(backups[0].item.elements), + originalElementsJson + ); + }); + + test("Creates a backup after move", async () => { + const originalElementsJson = getElementsJson(); + const elements = parseElements(originalElementsJson); + + await vscode.commands.executeCommand( + "snippet.test_moveElement", + elements[2].data.id, + elements[1].data.id + ); + const backups = await getBackups(); + + assert.strictEqual(backups.length, 4); + assert.strictEqual( + JSON.stringify(backups[0].item.elements), + originalElementsJson + ); + }); + + test("Restores backup", async () => { + sinon.stub(vscode.window, "showInputBox").callsFake(() => { + return Promise.resolve("new name"); + }); + sinon.stub(vscode.window, "showInformationMessage").callsFake(() => { + return Promise.resolve("Ok" as unknown as MessageItem); + }); + const originalElementsJson = getElementsJson(); + const snippet = getAnySnippet(originalElementsJson); + + await vscode.commands.executeCommand("snippet.renameSnippet", { + id: snippet.data.id, + }); + const backups = await getBackups(true); + + assert.strictEqual(backups.length, 5); + assert.strictEqual(getElementsJson(), originalElementsJson); + }); + + test("Undoes backup restoration", async () => { + sinon.stub(vscode.window, "showInformationMessage").callsFake(() => { + return Promise.resolve("Undo" as unknown as MessageItem); + }); + sinon.stub(vscode.window, "showInputBox").callsFake(() => { + return Promise.resolve("new name"); + }); + const originalElementsJson = getElementsJson(); + const snippet = getAnySnippet(originalElementsJson); + + await vscode.commands.executeCommand("snippet.renameSnippet", { + id: snippet.data.id, + }); + const elementsAfterChange = getElementsJson(); + const backups = await getBackups(true); + + assert.strictEqual(backups.length, 6); + assert.notStrictEqual(getElementsJson(), originalElementsJson); + assert.strictEqual(getElementsJson(), elementsAfterChange); + }); + + test("Saves up to 10 backups", async () => { + sinon.stub(vscode.window, "showInputBox").callsFake(() => { + return Promise.resolve("new name"); + }); + const originalElementsJson = getElementsJson(); + const snippet = getAnySnippet(originalElementsJson); + + for (let i = 0; i < 11; i++) { + await vscode.commands.executeCommand("snippet.renameSnippet", { + id: snippet.data.id, + }); + } + const backups = await getBackups(); + + assert.strictEqual(backups.length, 10); + }); +}); + +async function getBackups(restoreLatest = false): Promise { + return new Promise((resolve) => { + const showQuickPickStub = sinon.stub(vscode.window, "showQuickPick"); + + let result: BackupItem[] = []; + showQuickPickStub.callsFake(async (backups: BackupItem[]) => { + result = backups; + return restoreLatest && backups[0] ? backups[0] : null; + }); + + vscode.commands.executeCommand("snippet.restoreBackups").then(() => { + resolve(result); + }); + }); +} + +function getElementsJson(): string { + return cache.state.get("snippet.snippetsStorageKey") || "[]"; +} + +function parseElements(json: string): TreeElement[] { + return JSON.parse(json); +} + +function getAnySnippet(elementsJson: string): TreeElement { + return parseElements(elementsJson).find((x) => !SnippetsStorage.isFolder(x)); +} diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 565f3599..802ce125 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -1,6 +1,6 @@ +import * as sinon from "sinon"; import * as vscode from "vscode"; import { MockResponseData } from "../snippet"; -import * as sinon from "sinon"; export function getResponseFromResultDocument(): MockResponseData { const editors = vscode.window.visibleTextEditors.filter( @@ -34,7 +34,7 @@ export async function openDocument({ await config.update("openInNewEditor", openInNewEditor, configTarget); } -export async function openDocumentAndFindSelectedText({ +export async function openDocumentAndSelectText({ language = "javascript", queryText = Date.now().toString(), openInNewEditor, @@ -51,6 +51,22 @@ export async function openDocumentAndFindSelectedText({ 0, queryText.length ); +} + +export async function openDocumentAndFindSelectedText({ + language = "javascript", + queryText = Date.now().toString(), + openInNewEditor, +}: { + language?: string; + queryText?: string; + openInNewEditor: boolean; +}): Promise { + await openDocumentAndSelectText({ + language, + queryText, + openInNewEditor, + }); await vscode.commands.executeCommand("snippet.findSelectedText"); }