Add delete action to bulk edit dialog

Adds a "Delete selected files" button alongside "Update properties"
in the bulk edit dialog, routing through app.fileManager.trashFile()
so the user's Obsidian trash preference is honored. The heading is
renamed to "Bulk edit selected files" to reflect the expanded scope.

The no-properties-configured branch no longer returns early so the
Delete button remains available when only a selection is present but
no editable properties are configured.

ConfirmModal gains optional confirmText and confirmStyle so the
delete confirmation can show a red "Delete" button rather than the
default CTA "Continue". doDelete() re-reads getCheckedFiles() after
awaiting pendingSaves to avoid confirming on a stale positive count
that resolves to zero.
This commit is contained in:
Gary Ritchie 2026-04-04 14:40:16 -06:00
parent 6a6d8cb50a
commit bc8a5a1fd5
No known key found for this signature in database
GPG key ID: A37A622ED6F91DD0
2 changed files with 142 additions and 47 deletions

View file

@ -1,7 +1,7 @@
import {AbstractInputSuggest, App, Modal, Notice, setIcon, Setting, TFile} from "obsidian";
import type BulkPropertiesPlugin from "./main";
import {getPropertyValues, getSelectedFiles} from "./files";
import {confirmEmptyValue, confirmReplace} from "./confirm-modal";
import {confirmDeleteFiles, confirmEmptyValue, confirmReplace} from "./confirm-modal";
import {withProgress} from "./progress";
import {makeToggleAccessible, updateToggleAriaChecked} from "./accessible-toggle";
@ -119,7 +119,8 @@ export class BulkEditModal extends Modal {
private countEl!: HTMLElement;
private pendingSaves: Map<TFile, Promise<void>> = new Map();
private fileCheckboxes: Map<TFile, HTMLInputElement> = new Map();
private updateBtn!: HTMLButtonElement;
private updateBtn?: HTMLButtonElement;
private deleteBtn!: HTMLButtonElement;
private selectAllBtn!: HTMLButtonElement;
private deselectAllBtn!: HTMLButtonElement;
private uiLocked = false;
@ -140,7 +141,7 @@ export class BulkEditModal extends Modal {
const {settings} = this.plugin;
contentEl.addClass("bulk-properties-modal");
new Setting(contentEl).setName("Bulk edit properties").setHeading();
new Setting(contentEl).setName("Bulk edit selected files").setHeading();
if (this.fileSelection.size === 0) {
contentEl.createEl("p", {
@ -180,8 +181,9 @@ export class BulkEditModal extends Modal {
});
const editableProperties = settings.properties.filter(p => p.name !== settings.selectionProperty);
const hasEditableProperties = editableProperties.length > 0;
if (editableProperties.length === 0) {
if (!hasEditableProperties) {
const p = contentEl.createEl("p");
p.appendText("No properties configured. Add properties in the ");
// eslint-disable-next-line obsidianmd/ui/sentence-case -- mid-sentence text
@ -201,51 +203,63 @@ export class BulkEditModal extends Modal {
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */
});
p.appendText(".");
return;
}
const lastSelected = settings.lastSelectedProperty;
const rememberedExists = lastSelected && editableProperties.some(p => p.name === lastSelected);
this.selectedProperty = rememberedExists ? lastSelected : editableProperties[0]?.name ?? "";
if (hasEditableProperties) {
const lastSelected = settings.lastSelectedProperty;
const rememberedExists = lastSelected && editableProperties.some(p => p.name === lastSelected);
this.selectedProperty = rememberedExists ? lastSelected : editableProperties[0]?.name ?? "";
new Setting(contentEl)
.setName("Property")
.addDropdown(dropdown => {
for (const prop of editableProperties) {
dropdown.addOption(prop.name, prop.name);
}
dropdown.setValue(this.selectedProperty);
dropdown.onChange(value => {
this.selectedProperty = value;
this.renderValueInput();
});
});
this.valueContainerEl = contentEl.createDiv();
this.renderValueInput();
new Setting(contentEl)
.setName("Deselect when finished")
.addToggle(toggle => {
makeToggleAccessible(toggle, "Deselect when finished", this.deselectWhenFinished);
toggle
.setValue(this.deselectWhenFinished)
.onChange(value => {
this.deselectWhenFinished = value;
updateToggleAriaChecked(toggle, value);
new Setting(contentEl)
.setName("Property")
.addDropdown(dropdown => {
for (const prop of editableProperties) {
dropdown.addOption(prop.name, prop.name);
}
dropdown.setValue(this.selectedProperty);
dropdown.onChange(value => {
this.selectedProperty = value;
this.renderValueInput();
});
});
});
new Setting(contentEl)
this.valueContainerEl = contentEl.createDiv();
this.renderValueInput();
new Setting(contentEl)
.setName("Deselect when finished")
.addToggle(toggle => {
makeToggleAccessible(toggle, "Deselect when finished", this.deselectWhenFinished);
toggle
.setValue(this.deselectWhenFinished)
.onChange(value => {
this.deselectWhenFinished = value;
updateToggleAriaChecked(toggle, value);
});
});
}
const footer = new Setting(contentEl)
.addButton(btn => {
btn
.setButtonText("Update")
.setButtonText("Delete selected files")
.setWarning()
.onClick(() => {
void this.doDelete();
});
this.deleteBtn = btn.buttonEl;
});
if (hasEditableProperties) {
footer.addButton(btn => {
btn
.setButtonText("Update properties")
.setCta()
.onClick(() => {
void this.doUpdate();
});
this.updateBtn = btn.buttonEl;
});
}
}
override onClose() {
@ -262,10 +276,15 @@ export class BulkEditModal extends Modal {
const checked = this.getCheckedFiles().length;
const total = this.fileSelection.size;
this.countEl.setText(`${checked} of ${total} file${total === 1 ? "" : "s"} selected`);
if (this.updateBtn && !this.uiLocked) {
const hasUncommitted = this.activePillInput !== null
&& this.activePillInput.value.trim() !== "";
this.updateBtn.disabled = checked === 0 || hasUncommitted;
if (!this.uiLocked) {
if (this.updateBtn) {
const hasUncommitted = this.activePillInput !== null
&& this.activePillInput.value.trim() !== "";
this.updateBtn.disabled = checked === 0 || hasUncommitted;
}
if (this.deleteBtn) {
this.deleteBtn.disabled = checked === 0;
}
}
}
@ -664,10 +683,14 @@ export class BulkEditModal extends Modal {
}
if (this.selectAllBtn) this.selectAllBtn.disabled = !enabled;
if (this.deselectAllBtn) this.deselectAllBtn.disabled = !enabled;
const checkedCount = this.getCheckedFiles().length;
if (this.updateBtn) {
const hasUncommitted = this.activePillInput !== null
&& this.activePillInput.value.trim() !== "";
this.updateBtn.disabled = !enabled || this.getCheckedFiles().length === 0 || hasUncommitted;
this.updateBtn.disabled = !enabled || checkedCount === 0 || hasUncommitted;
}
if (this.deleteBtn) {
this.deleteBtn.disabled = !enabled || checkedCount === 0;
}
}
@ -802,4 +825,43 @@ export class BulkEditModal extends Modal {
}
new Notice(msg);
}
private async doDelete() {
this.uiLocked = true;
this.setUIEnabled(false);
await Promise.all(this.pendingSaves.values());
const filesToDelete = this.getCheckedFiles();
if (filesToDelete.length === 0) {
this.uiLocked = false;
this.setUIEnabled(true);
new Notice("No files selected");
return;
}
const confirmed = await confirmDeleteFiles(this.app, filesToDelete.length);
if (!confirmed) {
this.uiLocked = false;
this.setUIEnabled(true);
return;
}
this.close();
const result = await withProgress(
filesToDelete,
"Deleting",
(file) => this.app.fileManager.trashFile(file),
);
const {succeeded, failed, cancelled, total} = result;
let msg = `Deleted ${succeeded} file${succeeded === 1 ? "" : "s"}`;
if (cancelled) {
msg = `Deleted ${succeeded} of ${total} file${total === 1 ? "" : "s"} (cancelled)`;
}
if (failed.length > 0) {
msg += `, failed on ${failed.length}: ${failed.join(", ")}`;
}
new Notice(msg);
}
}

View file

@ -1,28 +1,47 @@
import {App, Modal, Setting} from "obsidian";
interface ConfirmOptions {
confirmText?: string;
confirmStyle?: "cta" | "warning";
}
class ConfirmModal extends Modal {
private resolved = false;
private resolve: (value: boolean) => void;
private readonly message: string;
private readonly confirmText: string;
private readonly confirmStyle: "cta" | "warning";
constructor(app: App, message: string, resolve: (value: boolean) => void) {
constructor(
app: App,
message: string,
resolve: (value: boolean) => void,
options?: ConfirmOptions,
) {
super(app);
this.message = message;
this.resolve = resolve;
this.confirmText = options?.confirmText ?? "Continue";
this.confirmStyle = options?.confirmStyle ?? "cta";
}
override onOpen() {
const {contentEl} = this;
contentEl.createEl("p", {text: this.message});
new Setting(contentEl)
.addButton(btn => btn
.setButtonText("Continue")
.setCta()
.onClick(() => {
.addButton(btn => {
btn.setButtonText(this.confirmText);
if (this.confirmStyle === "warning") {
btn.setWarning();
} else {
btn.setCta();
}
btn.onClick(() => {
this.resolved = true;
this.resolve(true);
this.close();
}))
});
})
.addButton(btn => btn
.setButtonText("Cancel")
.onClick(() => {
@ -74,3 +93,17 @@ export function confirmReplace(
new ConfirmModal(app, message, resolve).open();
});
}
export function confirmDeleteFiles(
app: App,
fileCount: number,
): Promise<boolean> {
const noun = fileCount === 1 ? "file" : "files";
const message = `This will delete ${fileCount} ${noun}. Are you sure?`;
return new Promise<boolean>(resolve => {
new ConfirmModal(app, message, resolve, {
confirmText: "Delete",
confirmStyle: "warning",
}).open();
});
}