diff --git a/CHANGELOG.md b/CHANGELOG.md index 32d46ee80..f230e4065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Add R language support for PROC R (syntax highlighting, notebook cells, code formatting preservation) ([#1719](https://github.com/sassoftware/vscode-sas-extension/pull/1719)) - Add the ability to pin columns ([#1781](https://github.com/sassoftware/vscode-sas-extension/pull/1781)) - Allow running R code files in SAS Content ([#1845](https://github.com/sassoftware/vscode-sas-extension/pull/1845)) +- Add support for deleting multiple files and folders at once ([#1846](https://github.com/sassoftware/vscode-sas-extension/pull/1846)) ## [1.19.1] - 2026-04-01 diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index edaaae8e2..23c2074bd 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -362,10 +362,18 @@ class ContentDataProvider return this.model.saveContentToUri(uri, new TextDecoder().decode(content)); } - public async deleteResource(item: ContentItem): Promise { - if (!(await closeFileIfOpen(item))) { - return false; + public async closeResourceFiles(items: ContentItem[]): Promise { + for (const item of items) { + const result = await closeFileIfOpen(item); + if (result === false) { + // User canceled the save dialog + return false; + } } + return true; + } + + public async deleteResource(item: ContentItem): Promise { const success = await this.model.delete(item); if (success) { this.refresh(); @@ -379,10 +387,6 @@ class ContentDataProvider } public async recycleResource(item: ContentItem): Promise { - if (!(await closeFileIfOpen(item))) { - return false; - } - const { newUri, oldUri } = await this.model.recycleResource(item); if (newUri) { @@ -847,23 +851,32 @@ class ContentDataProvider export default ContentDataProvider; -const closeFileIfOpen = (item: ContentItem): Promise | boolean => { +const closeFileIfOpen = (item: ContentItem): Promise | true => { const tabs = getEditorTabsForItem(item); if (tabs.length > 0) { return new Promise((resolve, reject) => { Promise.all(tabs.map((tab) => window.tabGroups.close(tab))) - .then(() => - resolve( - tabs - .map( - (tab) => - (tab.input instanceof TabInputText || - tab.input instanceof TabInputNotebook) && - tab.input.uri, - ) - .filter((exists) => exists), - ), - ) + .then((results) => { + // Check if all tabs were successfully closed + const allClosed = results.every((result) => result === true); + if (!allClosed) { + // At least one tab could not be closed, e.g. the user declined to save + resolve(false); + return; + } + // All tabs closed successfully, return their URIs + const closedUris: Uri[] = []; + for (const tab of tabs) { + if ( + (tab.input instanceof TabInputText || + tab.input instanceof TabInputNotebook) && + tab.input.uri + ) { + closedUris.push(tab.input.uri); + } + } + resolve(closedUris); + }) .catch(reject); }); } diff --git a/client/src/components/ContentNavigator/const.ts b/client/src/components/ContentNavigator/const.ts index d9245b93b..7aa9adefc 100644 --- a/client/src/components/ContentNavigator/const.ts +++ b/client/src/components/ContentNavigator/const.ts @@ -65,10 +65,13 @@ export const Messages = { DeleteButtonLabel: l10n.t("Delete"), MoveToRecycleBinLabel: l10n.t("Move to Recycle Bin"), DeleteWarningMessage: l10n.t( - 'Are you sure you want to permanently delete the item "{name}"?', + 'Are you sure you want to permanently delete "{name}"?', ), - RecycleDirtyFolderWarning: l10n.t( - "This folder contains unsaved files, are you sure you want to delete?", + DeleteMultipleWarningMessage: l10n.t( + "Are you sure you want to permanently delete {count} items?", + ), + DirtyFolderWarning: l10n.t( + "The selected folders contain unsaved files, are you sure you want to delete?", ), EmptyRecycleBinError: l10n.t("Unable to empty the recycle bin."), EmptyRecycleBinWarningMessage: l10n.t( diff --git a/client/src/components/ContentNavigator/index.ts b/client/src/components/ContentNavigator/index.ts index a7e8f0719..091e8fd36 100644 --- a/client/src/components/ContentNavigator/index.ts +++ b/client/src/components/ContentNavigator/index.ts @@ -102,54 +102,114 @@ class ContentNavigator implements SubscriptionProvider { commands.registerCommand( `${SAS}.deleteResource`, async (item: ContentItem) => { - this.getTreeViewSelections(item).forEach( - async (resource: ContentItem) => { - if (!resource.contextValue.includes("delete")) { - return; - } - const isContainer = getIsContainer(resource); - const hasUnsavedFiles = isContainer - ? await this.contentDataProvider.checkFolderDirty(resource) - : false; - const moveToRecycleBin = - this.contentDataProvider.canRecycleResource(resource); + const selections = this.getTreeViewSelections(item); - if ( - !moveToRecycleBin && - !(await window.showWarningMessage( - l10n.t(Messages.DeleteWarningMessage, { - name: resource.name, - }), - { modal: true }, - Messages.DeleteButtonLabel, - )) - ) { - return; - } else if (moveToRecycleBin && hasUnsavedFiles) { - if ( - !(await window.showWarningMessage( - l10n.t(Messages.RecycleDirtyFolderWarning, { - name: resource.name, - }), - { modal: true }, - Messages.MoveToRecycleBinLabel, - )) - ) { - return; - } - } - const deleteResult = moveToRecycleBin - ? await this.contentDataProvider.recycleResource(resource) - : await this.contentDataProvider.deleteResource(resource); - if (!deleteResult) { - window.showErrorMessage( - isContainer - ? Messages.FolderDeletionError - : Messages.FileDeletionError, - ); - } - }, + const deletableItems = selections.filter((resource: ContentItem) => + resource.contextValue?.includes("delete"), + ); + + if (deletableItems.length === 0) { + return; + } + + // Close all open files first and handle unsaved changes + // If user cancels the save dialog, abort the deletion + if ( + !(await this.contentDataProvider.closeResourceFiles(deletableItems)) + ) { + return; + } + + const recyclableItems = deletableItems.filter( + (resource: ContentItem) => + this.contentDataProvider.canRecycleResource(resource), ); + const permanentDelete = + deletableItems.length > recyclableItems.length; + + let hasUnsavedFiles = false; + for (const resource of deletableItems) { + const isContainer = getIsContainer(resource); + if ( + isContainer && + (await this.contentDataProvider.checkFolderDirty(resource)) + ) { + hasUnsavedFiles = true; + break; + } + } + + let confirmed = false; + if (deletableItems.length === 1) { + const resource = deletableItems[0]; + const isContainer = getIsContainer(resource); + const canRecycle = + this.contentDataProvider.canRecycleResource(resource); + const itemHasUnsavedFiles = isContainer + ? await this.contentDataProvider.checkFolderDirty(resource) + : false; + + if (!canRecycle) { + confirmed = !!(await window.showWarningMessage( + l10n.t(Messages.DeleteWarningMessage, { + name: resource.name, + }), + { modal: true }, + Messages.DeleteButtonLabel, + )); + } else if (itemHasUnsavedFiles) { + confirmed = !!(await window.showWarningMessage( + l10n.t(Messages.DirtyFolderWarning), + { modal: true }, + canRecycle + ? Messages.MoveToRecycleBinLabel + : Messages.DeleteButtonLabel, + )); + } else { + confirmed = true; + } + } else { + if (permanentDelete) { + confirmed = !!(await window.showWarningMessage( + l10n.t(Messages.DeleteMultipleWarningMessage, { + count: deletableItems.length, + }), + { modal: true }, + Messages.DeleteButtonLabel, + )); + } else if (hasUnsavedFiles) { + confirmed = !!(await window.showWarningMessage( + l10n.t(Messages.DirtyFolderWarning), + { modal: true }, + permanentDelete + ? Messages.DeleteButtonLabel + : Messages.MoveToRecycleBinLabel, + )); + } else { + confirmed = true; + } + } + + if (!confirmed) { + return; + } + + for (const resource of deletableItems) { + const isContainer = getIsContainer(resource); + const canRecycle = + this.contentDataProvider.canRecycleResource(resource); + const deleteResult = canRecycle + ? await this.contentDataProvider.recycleResource(resource) + : await this.contentDataProvider.deleteResource(resource); + + if (!deleteResult) { + window.showErrorMessage( + isContainer + ? Messages.FolderDeletionError + : Messages.FileDeletionError, + ); + } + } }, ), commands.registerCommand(