0.112.11: friendlier Remove encryption, trash management, settings reliability

This commit is contained in:
Human 2026-06-19 09:01:16 -07:00
parent 6257018293
commit ec6cc151ac
14 changed files with 606 additions and 196 deletions

140
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"id": "stashpad",
"name": "Stashpad",
"version": "0.111.0",
"version": "0.112.11",
"minAppVersion": "1.13.0",
"description": "A chat-style, nested-notes workspace: rapid capture, outliner navigation, fast search, tasks, and per-folder templates, with one-click Open Knowledge Format (OKF) export for LLMs and agents.",
"author": "Human",

View file

@ -1,6 +1,6 @@
{
"name": "stashpad-obsidian",
"version": "0.111.0",
"version": "0.112.11",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",

26
release-notes/0.112.11.md Normal file
View file

@ -0,0 +1,26 @@
# 0.112.11 — Friendlier "Remove encryption" + settings reliability
## Removing encryption is now simple and unblockable
- **No password retyping.** Removing encryption only asks you to type the confirmation
phrase — not your password. So if you've **lost the password**, you can still remove
encryption and start fresh.
- **Locked notes:** if anything is still encrypted (locked), removal offers to either
**decrypt everything** (back to normal notes) or, if you've lost the password,
**permanently delete the locked content** — then proceeds.
- **Encrypted trash:** a clear choice to **Decrypt** it back to your folders or
**Discard** it (with a second confirmation). Discarding now actually deletes the trash
files rather than leaving unreadable leftovers.
- The "is anything still encrypted?" check is now near-instant instead of scanning the
whole vault.
## Encrypted trash tab
- **Permanent delete** of individual items (with a "can't be restored" warning),
**multi-select** (click, ⌘/Ctrl-click, Shift-range), bulk **Restore/Delete selected**,
and **⌘/Ctrl+A** to select all.
## Settings reliability
- Fixed a bug where the **settings tab could render blank** (it stopped on a button
that used an API not present on all builds), and another where it **didn't refresh**
after actions like removing encryption.
- Settings sections are now listed **alphabetically**.
- Hardened rendering so a single misbehaving control can no longer blank the whole tab.

View file

@ -17,7 +17,7 @@
// Node 18+ (global fetch / WebSocket). macOS paths.
import { spawn } from "node:child_process";
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, copyFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve, join } from "node:path";
import { execSync } from "node:child_process";
@ -110,6 +110,30 @@ function seedConfig() {
writeFileSync(cfgPath, JSON.stringify(cfg));
}
// Obsidian auto-updates by writing a newer `obsidian-<ver>.asar` into the
// user-data-dir (NOT the .app bundle), and loads the newest asar present there.
// The dev profile is isolated, so it never receives those updates and runs the
// bundled (older, stable) version — which can render/behave differently from the
// user's main app (e.g. the 1.13 declarative-settings API is absent on stable, so
// dev tests silently exercise a different code path). Mirror the main profile's
// newest asar into the dev profile so the dev instance matches the real app.
const MAIN_OBSIDIAN_DIR = join(process.env.HOME, "Library", "Application Support", "obsidian");
function syncInsiderAsar() {
try {
if (!existsSync(MAIN_OBSIDIAN_DIR)) return;
const verOf = (f) => (f.match(/(\d+)\.(\d+)\.(\d+)/) || []).slice(1).map(Number);
const cmp = (a, b) => { for (let i = 0; i < 3; i++) { const d = (verOf(a)[i] ?? 0) - (verOf(b)[i] ?? 0); if (d) return d; } return 0; };
const asars = readdirSync(MAIN_OBSIDIAN_DIR).filter((f) => /^obsidian-.*\.asar$/.test(f)).sort(cmp);
if (!asars.length) return; // main app on the bundled version too — nothing to mirror
const newest = asars[asars.length - 1];
const dst = join(USER_DATA_DIR, newest);
if (!existsSync(dst)) {
copyFileSync(join(MAIN_OBSIDIAN_DIR, newest), dst);
console.log(`synced ${newest} into the dev profile (matches your main Obsidian version)`);
}
} catch (e) { console.warn("asar sync skipped:", e.message); }
}
async function start() {
if (await portReady()) {
const v = await verifyVault();
@ -117,6 +141,7 @@ async function start() {
return;
}
seedConfig();
syncInsiderAsar();
console.log(`launching dedicated Obsidian (vault: ${VAULT_NAME}, port :${PORT}, isolated user-data-dir)…`);
const child = spawn(OBSIDIAN_BIN, [`--remote-debugging-port=${PORT}`, `--user-data-dir=${USER_DATA_DIR}`], {
detached: true, stdio: "ignore",

92
scripts/seed-demo.mjs Normal file
View file

@ -0,0 +1,92 @@
/* Seed a rich, nested demo note-folder for website screenshots.
* node scripts/seed-demo.mjs "/abs/path/to/vault" "My Stash" [--force]
* Filenames are human-readable (drives Stashpad's breadcrumb/header titles);
* the canonical identity is the frontmatter `id`. --force replaces the folder
* (only ever the demo folder it generates never touches sibling notes).
*/
import { mkdir, writeFile, access, rm } from "node:fs/promises";
import { join } from "node:path";
const vault = process.argv[2];
const folder = process.argv[3] || "My Stash";
const force = process.argv.includes("--force");
if (!vault) { console.error("usage: seed-demo.mjs <vaultPath> [folder] [--force]"); process.exit(1); }
const dir = join(vault, folder);
let exists = false;
try { await access(dir); exists = true; } catch {}
if (exists && !force) { console.error(`REFUSED: ${dir} exists (use --force to regenerate)`); process.exit(1); }
if (exists && force) await rm(dir, { recursive: true });
await mkdir(dir, { recursive: true });
let t = Date.parse("2026-06-12T09:00:00");
const stamp = () => { const d = new Date(t); t += 60_000; return d.toISOString().slice(0, 19); };
const notes = [];
const byId = new Map();
// id, parent, name(=filename/title), body, extra
const add = (id, parent, name, body, extra = {}) => {
const n = { id, parent, name, created: stamp(), body: body.trim(), extra };
notes.push(n); byId.set(id, n);
};
await writeFile(join(dir, `Home.md`),
`---\nid: __root__\nparent: __root__\ncreated: ${stamp()}\nattachments: []\n---\n# My Stash\nEverything I'm thinking about, one stack at a time.\n`);
// ── 1. Reading list ──────────────────────────────────────────────────────
add("read", "__root__", "Reading list", `📚 **Reading list** — what I'm working through and the bits worth keeping.`);
add("read-ah", "read", "Atomic Habits", `📕 **Atomic Habits** — James Clear. The core move: don't set goals, design systems. You don't rise to the level of your goals — you fall to the level of your systems.`);
add("read-ah-hs", "read-ah", "Habit stacking", `**Habit stacking** to try this week: "After I pour my morning coffee, I will write three lines in my stash." Anchor the new habit to one I already never skip.`);
add("read-ah-2", "read-ah", "The two-minute rule", `**The 2-minute rule:** scale any new habit down until it takes two minutes. "Read before bed" → "read one page." Make it so easy you can't say no.`);
add("read-tfs", "read", "Thinking Fast and Slow", `📗 **Thinking, Fast and Slow** — Kahneman. System 1 is fast, intuitive, wrong a lot. System 2 is slow, deliberate, lazy. Most "intuition" is System 1 pattern-matching wearing a confident face.`);
add("read-pp", "read", "The Pragmatic Programmer", `📘 **The Pragmatic Programmer** — Hunt & Thomas. DRY isn't about code, it's about knowledge. Two snippets can look identical and not be a violation if they encode different decisions.`);
// ── 2. Japan trip ────────────────────────────────────────────────────────
add("jp", "__root__", "Japan trip — March", `✈️ **Japan trip — March** 🌸 Nine days, Tokyo → Kyoto → back. Blossom forecast says peak around the 28th. Fingers crossed.`);
add("jp-tok", "jp", "Tokyo", `**Tokyo** (5 nights, Airbnb in Shimokitazawa). Base in the west, day-trip out. Grab a Suica card at the airport and top it up before anything else.`);
add("jp-tok-d1", "jp-tok", "Day 1 — arrival", `**Day 1** — recover from the flight slowly. Shimokitazawa vintage shops in the morning, Shibuya crossing at dusk, conveyor-belt sushi when we can't keep our eyes open.`);
add("jp-tok-d2", "jp-tok", "Day 2 — teamLab", `**Day 2** — teamLab Planets (book tickets NOW, they sell out), then Tsukiji outer market for lunch. Evening: tiny bars in Golden Gai.`);
add("jp-kyo", "jp", "Kyoto", `**Kyoto** (3 nights). Slower pace. Fushimi Inari at sunrise to beat the crowds, Arashiyama bamboo grove, an afternoon doing absolutely nothing in a tea house.`);
add("jp-pack", "jp", "Packing list", `**Packing** — pack light, you'll buy stuff. Comfortable walking shoes (20k steps/day), a foldable tote, portable battery, and cash — Japan still loves cash.`);
add("jp-budget", "jp", "Budget", `**Budget** — flights booked. Aim for ¥12k/day for food + transit per person. Splurge once on a proper kaiseki dinner in Kyoto; balance it with conbini breakfasts.`);
// ── 3. Recipes ───────────────────────────────────────────────────────────
add("rec", "__root__", "Recipes", `🍳 **Recipes** — the ones that actually made it into rotation.`);
add("rec-ramen", "rec", "Weeknight miso ramen", `**Weeknight miso ramen** (20 min). Soften garlic + ginger in sesame oil, whisk in 2 tbsp miso + a splash of soy, add stock. Noodles, soft egg, whatever greens are wilting in the drawer. Done.`);
add("rec-sd", "rec", "Sourdough method", `**Sourdough — my method.** 500g flour, 350g water, 100g active starter, 10g salt. Autolyse 1h, 4 stretch-and-folds, bulk till 50% risen, shape, cold-proof overnight, bake in a dutch oven at 250°C.`);
add("rec-chx", "rec", "Sheet-pan harissa chicken", `**Sheet-pan harissa chicken.** Thighs + chickpeas + red onion tossed in harissa, honey, lemon. 220°C for 35 min. Finish with yogurt and mint. Feeds 4, dirties one pan.`);
// ── 4. Rust ──────────────────────────────────────────────────────────────
add("rust", "__root__", "Rust — learning notes", `🦀 **Rust — learning notes.** Slowly fighting the borrow checker less and understanding it more.`);
add("rust-own", "rust", "Ownership", `**Ownership.** Every value has exactly one owner; when the owner goes out of scope, the value is dropped. Move semantics by default — assigning transfers ownership, it doesn't copy.`);
add("rust-bor", "rust", "Borrowing", `**Borrowing.** Either one mutable reference *or* any number of immutable ones — never both at once. That's the whole game: data races become compile errors.`);
add("rust-life", "rust", "Lifetimes", `**Lifetimes, finally clicking.** An annotation doesn't change how long anything lives — it *describes* a relationship the compiler can't infer. \`'a\` means "lives at least as long as 'a."`);
add("rust-err", "rust", "Error handling", `**Error handling.** No exceptions — \`Result<T, E>\` and the \`?\` operator. \`?\` early-returns the error, otherwise unwraps the Ok. \`thiserror\` for libraries, \`anyhow\` for apps.`);
// ── 5. Ideas inbox ───────────────────────────────────────────────────────
add("idea", "__root__", "Ideas inbox", `💡 **Ideas inbox** — capture now, judge later. Most won't survive the week, and that's fine.`);
add("idea-out", "idea", "Why outliners beat documents", `**Blog post: why outliners beat documents.** A document forces one order. An outliner lets structure emerge — write the thoughts, then drag them into shape. Working title: "Structure is a verb."`);
add("idea-plant", "idea", "Plant-care reminders app", `**App idea: plant-care reminders** that account for season + light. Not "water every Tuesday" but "this fern, this window, this month." Probably already exists. Capture anyway.`);
// ── 6. This week (tasks) ─────────────────────────────────────────────────
add("week", "__root__", "This week", `🎯 **This week** — the short list. If it's not here, it's not happening before Sunday.`);
add("week-invoice", "week", "Send invoice to Meridian", `Send the invoice to Meridian for the May work.`, { task: true, due: "2026-06-20T17:00:00" });
add("week-dentist", "week", "Book dentist cleaning", `Book the dentist cleaning — it's been a year.`, { task: true, due: "2026-06-23T09:00:00" });
add("week-passport", "week", "Renew passport", `Renew the passport before it expires (need it for Japan!).`, { task: true, due: "2026-06-25T12:00:00" });
add("week-itin", "week", "Draft itinerary email", `Draft the Japan itinerary email for the group.`, { task: true, completed: true, due: "2026-06-18T20:00:00" });
// ── 7. Home projects ─────────────────────────────────────────────────────
add("home", "__root__", "Home projects", `🏠 **Home projects** — the slow-burn list.`);
add("home-shelf", "home", "Garage shelving", `**Garage shelving.** 2×4 frame, plywood shelves, lag-bolted into the studs. Measure twice: the door swing eats 80cm on the left wall.`);
add("home-faucet", "home", "Fix leaky faucet", `**Fix the leaky bathroom faucet.** Almost certainly a worn cartridge — match the brand first. Shut off the supply valves *before* getting cocky.`, { task: true, due: "2026-06-28T10:00:00" });
for (const n of notes) {
const fm = [`id: ${n.id}`, `parent: ${n.parent}`, `created: ${n.created}`, `attachments: []`];
if (n.extra.task) fm.push(`task: true`);
if (n.extra.completed) fm.push(`completed: true`);
if (n.extra.due) fm.push(`due: ${n.extra.due}`);
const parentName = n.parent === "__root__" ? "Home" : byId.get(n.parent)?.name || "Home";
fm.push(`parentLink: "[[${folder}/${parentName}]]"`);
await writeFile(join(dir, `${n.name}.md`), `---\n${fm.join("\n")}\n---\n${n.body}\n`);
}
console.log(JSON.stringify({ folder: dir, count: notes.length + 1 }, null, 2));

View file

@ -313,24 +313,13 @@ export async function deletedRestoreDest(app: App, blobPath: string, meta: Delet
return blobDir;
}
/** Is ANY `.stashenc` blob on disk? Walks the ADAPTER (not vault.getFiles)
* adapter-written blobs can be invisible to the vault index for a while, and
* this backs the "Remove encryption" hard guard, which must never miss one.
* Skips `.obsidian/` only. */
export async function anyStashencOnDisk(app: App): Promise<boolean> {
const queue = [""];
while (queue.length) {
const dir = queue.shift()!;
let listing;
try { listing = await app.vault.adapter.list(dir || "/"); } catch { continue; }
if (listing.files.some((f) => f.endsWith(`.${STASHENC_EXT}`))) return true;
for (const f of listing.folders) {
if (f === ".obsidian" || f.endsWith("/.obsidian")) continue;
queue.push(f);
}
}
return false;
}
// 0.112.2: `anyStashencOnDisk()` (a full recursive adapter walk) was removed —
// it took minutes on big vaults and its premise was wrong. Empirically (Claude
// Dev Vault, pendingEncBlobs empty): `vault.getFiles()` DOES return
// `_deleted/*.stashenc` (it's a normal, fully-indexed folder), so the old
// comment "`_deleted/` blobs aren't indexed" was stale. The only location
// `getFiles()` misses is the `.trash/` DOT-folder, which `plugin.encryptionState`
// + `encryptionStateStrict` now handle directly. Use those instead.
/** Encrypt-delete a subtree into `_deleted/` (recoverable, encrypted), then
* permanently delete the plaintext. Mirrors lockSubtree but the blob lives in the
@ -411,6 +400,14 @@ export async function readDeletedMeta(app: App, blobPath: string): Promise<Delet
catch { return null; }
}
/** Permanently delete an encrypted-trash item: the `.stashenc` blob AND its
* `.stashmeta` sidecar. No decrypt, no restore the content is GONE forever.
* Callers MUST confirm first (it's irreversible). 0.112.3. */
export async function purgeDeletedBlob(app: App, blobPath: string): Promise<void> {
await app.vault.adapter.remove(blobPath);
try { await app.vault.adapter.remove(sidecarPath(blobPath)); } catch { /* sidecar may not exist */ }
}
// ------- v2 backfill: encrypt Obsidian's pre-existing plaintext trash -------
/** Obsidian's in-vault trash folder ("Deleted files" → "Move to vault trash"). */

View file

@ -112,6 +112,23 @@ export class EncryptionService {
isRemembered(): boolean {
try { return !!this.secretStore()?.getSecret(this.keychainId()); } catch { return false; }
}
/** The password saved in this device's keychain, or null. Lets flows that
* need the password (e.g. Remove encryption) pull it automatically instead
* of forcing the user to retype it. Falls back to the legacy entry. */
rememberedPassword(): string | null {
const ss = this.secretStore();
if (!ss) return null;
try { const v = ss.getSecret(this.keychainId()); if (v) return v; } catch { /* fall through */ }
try { return ss.getSecret(LEGACY_KEYCHAIN_ID) || null; } catch { return null; }
}
/** True if the keychain holds a password that actually verifies against the
* current key so a flow can treat "keychain present" as proof of access
* without prompting. */
async verifyWithKeychain(): Promise<boolean> {
const pw = this.rememberedPassword();
if (!pw) return false;
try { return await this.verifyPassword(pw); } catch { return false; }
}
private async remember(password: string): Promise<void> {
try { await this.secretStore()?.setSecret(this.keychainId(), password); }
catch (e) { console.warn("[Stashpad] couldn't save password to keychain", e); }

View file

@ -72,8 +72,10 @@ export class StashpadFolderPanelView extends ItemView {
he.onmousedown = (e) => { if (e.button === 0) { e.preventDefault(); this.plugin.openFolderPicker(); } };
const newBtn = head.createEl("button", { cls: "stashpad-folderpanel-iconbtn stashpad-folderpanel-heading-newfolder" });
setIcon(newBtn, "folder-plus");
newBtn.setAttr("aria-label", "New Stashpad folder");
newBtn.onmousedown = (e) => { if (e.button === 0) { e.preventDefault(); e.stopPropagation(); void this.createNewFolderFlow(); } };
newBtn.setAttr("aria-label", "New Stashpad folder (folder switcher)");
// 0.112.3: open the folder switcher (which already has a "Create" entry)
// rather than a bespoke prompt — one place to create + switch folders.
newBtn.onmousedown = (e) => { if (e.button === 0) { e.preventDefault(); e.stopPropagation(); this.plugin.openFolderPicker(); } };
if (this.plugin.encryption?.isConfigured?.()) {
const trashBtn = head.createEl("button", { cls: "stashpad-folderpanel-iconbtn stashpad-folderpanel-heading-trash" });
setIcon(trashBtn, "trash-2");
@ -83,23 +85,6 @@ export class StashpadFolderPanelView extends ItemView {
this.renderFolders(folderSection.createDiv({ cls: "stashpad-folderpanel-list" }));
}
/** Prompt for a name, create a new Stashpad folder, then open it. Wired to the
* "+" button in the Folders heading. 0.111.0. */
private async createNewFolderFlow(): Promise<void> {
const raw = await new NewFolderPromptModal(this.app).prompt();
const cleaned = (raw ?? "").trim().replace(/^\/+|\/+$/g, "");
if (!cleaned) return; // cancelled or empty
try {
await this.plugin.createNewStashpad(cleaned);
await this.plugin.waitForStashpadFolder(cleaned, 2000);
await this.plugin.activateViewForFolder(cleaned);
this.scheduleRender();
new Notice(`Created Stashpad "${cleaned.split("/").pop()}".`);
} catch (e) {
new Notice(`Couldn't create folder: ${(e as Error).message}`);
}
}
private clampFrac(f: number): number {
if (!Number.isFinite(f)) return 0.5;
return Math.max(0.15, Math.min(0.85, f));
@ -769,51 +754,3 @@ export async function openFolderPanelView(app: App): Promise<void> {
await leaf.setViewState({ type: STASHPAD_FOLDER_PANEL_VIEW_TYPE, active: true });
app.workspace.revealLeaf(leaf);
}
/** Minimal single-line name prompt for "New Stashpad folder". Resolves the
* entered name, or null if cancelled (Esc / Cancel / empty). 0.111.0. */
class NewFolderPromptModal extends Modal {
private value = "";
private resolved = false;
private resolve: ((v: string | null) => void) | null = null;
prompt(): Promise<string | null> {
return new Promise((resolve) => { this.resolve = resolve; this.open(); });
}
onOpen(): void {
this.titleEl.setText("New Stashpad folder");
const { contentEl } = this;
contentEl.createEl("p", {
cls: "setting-item-description",
text: "Vault-relative folder path. It's created (with intermediates) and seeded with a Home note, then opened.",
});
const input = contentEl.createEl("input", { type: "text", cls: "stashpad-newfolder-input" });
input.placeholder = "my-stashpad";
input.setCssStyles({ width: "100%", marginBottom: "12px" });
input.addEventListener("input", () => { this.value = input.value; });
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); this.finish(input.value); }
else if (e.key === "Escape") { e.preventDefault(); this.finish(null); }
});
const btns = contentEl.createDiv({ cls: "modal-button-container" });
const create = btns.createEl("button", { text: "Create", cls: "mod-cta" });
create.onclick = () => this.finish(this.value);
const cancel = btns.createEl("button", { text: "Cancel" });
cancel.onclick = () => this.finish(null);
window.setTimeout(() => input.focus(), 0);
}
private finish(v: string | null): void {
if (this.resolved) return;
this.resolved = true;
this.resolve?.(v);
this.close();
}
onClose(): void {
this.contentEl.empty();
// Closed via the X / click-out without an explicit choice → treat as cancel.
if (!this.resolved) { this.resolved = true; this.resolve?.(null); }
}
}

View file

@ -8,7 +8,7 @@ import { STASHPAD_TRASH_VIEW_TYPE } from "./types";
import { StashpadPanelsView, openStashpadPanelsView, PANEL_REGISTRY, type PanelId } from "./panels-view";
import { StashpadFolderPanelView, openFolderPanelView } from "./folder-panel-view";
import { EncryptionService, defaultEncryptionConfig } from "./encryption-service";
import { lockSubtree, unlockBundle, readLockedMeta, type LockResult, deleteEncryptSubtree, restoreDeleted, listDeletedBlobs, readDeletedMeta, deletedRestoreDest, backfillTrashEncrypt, restoreRawTrash, OBSIDIAN_TRASH_DIR, type DeletedMeta, collectSubtree } from "./encryption-ops";
import { lockSubtree, unlockBundle, readLockedMeta, type LockResult, deleteEncryptSubtree, restoreDeleted, listDeletedBlobs, readDeletedMeta, deletedRestoreDest, backfillTrashEncrypt, restoreRawTrash, purgeDeletedBlob, OBSIDIAN_TRASH_DIR, type DeletedMeta, collectSubtree } from "./encryption-ops";
import { EncryptionPasswordModal } from "./modals";
import {
DEFAULT_SETTINGS, StashpadSettings, StashpadSettingTab, setSettings, SETTINGS_TABS,
@ -79,6 +79,11 @@ export default class StashpadPlugin extends Plugin {
* wikilink rewrites Obsidian does when slug-renames move files never
* bump `modified` (or add the local user as a contributor). */
rebootstrapInProgress = false;
/** 0.112.0: paths of `.stashenc` blobs written via the adapter THIS session
* that the vault's in-memory index may not have picked up yet. Backs the fast
* encryption-state check so the removal guard never has to do a full recursive
* adapter walk. Pruned by the create watcher (once indexed) and delete watcher. */
private pendingEncBlobs = new Set<string>();
/** 0.86.6: while `Date.now() < this`, a Stashpad view's activation
* auto-focus skips grabbing the composer. Set by the folder panel before it
* reveals/opens a leaf so tapping a pinned note on mobile doesn't pop the
@ -760,6 +765,11 @@ export default class StashpadPlugin extends Plugin {
// has settled so folder discovery + the "already has my stub" check
// are accurate (avoids creating a duplicate before the cache lists
// the existing one). New folders are seeded at creation time instead.
// 0.112.0: keep the fast encryption-state index honest — drop a pending blob
// once the vault indexes it (then getFiles() covers it), and reflect external
// or synced deletes so a stale pending entry can't falsely report "locked".
this.registerEvent(this.app.vault.on("create", (f) => { if (f.path.endsWith(".stashenc")) this.pendingEncBlobs.delete(f.path); }));
this.registerEvent(this.app.vault.on("delete", (f) => { if (f.path.endsWith(".stashenc")) this.pendingEncBlobs.delete(f.path); }));
this.app.workspace.onLayoutReady(() => {
// Vault is fully indexed now — safe to reconcile locked placeholders
// (drop entries whose blob is truly gone, add cross-device blobs).
@ -3130,6 +3140,60 @@ export default class StashpadPlugin extends Plugin {
});
}
/** 0.112.0: FAST encryption-state check replaces a full recursive adapter
* walk that made "Remove encryption" take minutes on big vaults. Reads
* Obsidian's in-memory file index (instant) plus this session's
* `pendingEncBlobs` (adapter writes not yet indexed). Splits LIVE locked
* content (notes/folders irrecoverable if the key is erased) from TRASH
* (`_deleted/` recoverable until the key goes). Short-circuits as soon as it
* knows both.
*
* 0.112.2: empirically verified (Claude Dev Vault, pendingEncBlobs empty)
* that `vault.getFiles()` DOES return `_deleted/*.stashenc` it's a normal,
* fully-indexed folder so trash detection here is reliable across sessions,
* NOT dependent on `pendingEncBlobs`. The only location `getFiles()` misses is
* the `.trash/` DOT-folder; for any IRREVERSIBLE decision use
* `encryptionStateStrict()`, which adapter-confirms `_deleted/` + `.trash/`. */
encryptionState(): { live: boolean; trash: boolean } {
const isTrash = (p: string): boolean => {
const dir = p.replace(/\/[^/]*$/, "");
return dir === "_deleted" || dir.startsWith("_deleted/");
};
let live = false, trash = false;
for (const f of this.app.vault.getFiles()) {
if (f.extension !== "stashenc") continue;
if (isTrash(f.path)) trash = true; else live = true;
if (live && trash) return { live, trash };
}
for (const p of this.pendingEncBlobs) {
if (isTrash(p)) trash = true; else live = true;
if (live && trash) break;
}
return { live, trash };
}
/** Authoritative-but-cheap variant for the IRREVERSIBLE "Remove encryption"
* moment. Starts from the fast in-memory check, then confirms TRASH via two
* adapter folder listings: `_deleted/` (covers an index lag) and Obsidian's
* vault-local trash `.trash/` a DOT-folder that `vault.getFiles()` never
* returns, so a `.stashenc` the user trashed there would otherwise be erased
* silently. Two listings, not a full vault walk. Live blobs only ever live in
* normal (indexed) Stashpad folders, so live detection stays on the fast path. */
async encryptionStateStrict(): Promise<{ live: boolean; trash: boolean }> {
const s = this.encryptionState();
let trash = s.trash;
if (!trash) {
try { trash = (await listDeletedBlobs(this.app)).length > 0; } catch { /* ignore */ }
}
if (!trash) {
try {
const t = await this.app.vault.adapter.list(OBSIDIAN_TRASH_DIR);
trash = t.files.some((f) => f.endsWith(".stashenc"));
} catch { /* no .trash dir — fine */ }
}
return { live: s.live, trash };
}
async lockNoteSubtree(folder: string, rootId: StashpadId, prevSibling: StashpadId | null = null, opts: { silent?: boolean; blobFolder?: string } = {}): Promise<LockResult | null> {
if (!(await this.ensureEncryptionUnlocked())) return null;
const dek = this.encryption.getSessionKey();
@ -3137,6 +3201,7 @@ export default class StashpadPlugin extends Plugin {
try {
const hideTitle = this.settings.hideLockedTitles ?? false;
const r = await lockSubtree(this.app, folder, rootId, dek, prevSibling, hideTitle, opts.blobFolder);
this.pendingEncBlobs.add(r.blobPath); // fast-state index: cover the pre-vault-index window
// Record a placeholder registry entry so the list shows a 🔒 stub where
// the note was. The blob may live in a different folder than the note's
// source (archive) — register it under the blob's actual folder.
@ -3188,6 +3253,9 @@ export default class StashpadPlugin extends Plugin {
const folder = (opts.destFolder ?? blobPath.replace(/\/[^/]*$/, "")).replace(/\/+$/, "");
try {
const r = await this.unlockBundleCore(blobPath, dek, opts.destFolder);
// Blob is removed via raw adapter (no vault 'delete' event) — prune the
// fast-index pending entry directly so it can't falsely report "locked".
this.pendingEncBlobs.delete(blobPath);
this.settings.lockedSubtrees = (this.settings.lockedSubtrees ?? []).filter((e) => e.blob !== blobPath);
await this.saveSettings();
if (!opts.silent) this.notifications.show({ message: `Unlocked ${r.notesWritten} note${r.notesWritten === 1 ? "" : "s"}.`, kind: "success", category: "system", folder });
@ -3317,6 +3385,7 @@ export default class StashpadPlugin extends Plugin {
// the general hide-locked-titles setting is off.
const hideTitle = (this.settings.hideLockedTitles ?? false) || (this.settings.encryptTrashFilenames ?? false);
const r = await deleteEncryptSubtree(this.app, folder, rootId, dek, deletedAt, hideTitle);
this.pendingEncBlobs.add(r.blobPath); // fast-state index
if (r.unpurged.length > 0) {
new Notice(`⚠️ Sent to encrypted trash, but ${r.unpurged.length} file${r.unpurged.length === 1 ? " is" : "s are"} still in plaintext (couldn't be removed or changed during the delete):\n${r.unpurged.join("\n")}`, 0);
}
@ -3339,6 +3408,7 @@ export default class StashpadPlugin extends Plugin {
if (meta?.kind === "rawtrash") {
try {
const r = await restoreRawTrash(this.app, blobPath, dek);
this.pendingEncBlobs.delete(blobPath);
if (!opts.silent) this.notifications.show({ message: `Restored ${r.filesWritten} file${r.filesWritten === 1 ? "" : "s"} to Obsidian's trash (${OBSIDIAN_TRASH_DIR}/).`, kind: "success", category: "system", folder: "" });
return true;
} catch (e) {
@ -3364,6 +3434,7 @@ export default class StashpadPlugin extends Plugin {
} catch { /* unreadable — skip */ }
}
const r = await restoreDeleted(this.app, blobPath, dek, existing);
this.pendingEncBlobs.delete(blobPath);
if (!opts.silent) this.notifications.show({ message: `Restored ${r.notesWritten} note${r.notesWritten === 1 ? "" : "s"} to “${r.restoredTo.split("/").pop()}”.`, kind: "success", category: "system", folder: r.restoredTo });
return true;
} catch (e) {
@ -3373,6 +3444,39 @@ export default class StashpadPlugin extends Plugin {
}
}
/** Permanently delete one encrypted-trash item (blob + sidecar). Irreversible
* no decrypt. The caller MUST confirm first. Returns true on success. */
async purgeDeletedAt(blobPath: string): Promise<boolean> {
try {
await purgeDeletedBlob(this.app, blobPath);
this.pendingEncBlobs.delete(blobPath);
return true;
} catch (e) {
console.warn("[Stashpad] purge-from-trash failed", blobPath, e);
new Notice(`Couldn't delete: ${(e as Error).message}`, 0);
return false;
}
}
/** Permanently delete EVERY live locked `.stashenc` bundle (+ sidecar) WITHOUT
* decrypting the notes inside are gone for good. Backs "Remove encryption
* Delete locked content" for when the password is lost. Excludes `_deleted/`
* trash (that's handled separately). Caller MUST confirm. Returns the count. */
async purgeAllLockedContent(): Promise<number> {
const isTrash = (p: string): boolean => { const d = p.replace(/\/[^/]*$/, ""); return d === "_deleted" || d.startsWith("_deleted/"); };
const blobs = new Set<string>();
for (const f of this.app.vault.getFiles()) if (f.extension === "stashenc" && !isTrash(f.path)) blobs.add(f.path);
for (const p of this.pendingEncBlobs) if (!isTrash(p)) blobs.add(p);
let n = 0;
for (const b of blobs) {
try { await purgeDeletedBlob(this.app, b); this.pendingEncBlobs.delete(b); n++; }
catch (e) { console.warn("[Stashpad] purge locked content failed", b, e); }
}
this.settings.lockedSubtrees = (this.settings.lockedSubtrees ?? []).filter((e) => !blobs.has(e.blob));
await this.saveSettings();
return n;
}
/** List the encrypted-trash contents (blob path + sidecar metadata). */
async listDeletedTrash(): Promise<Array<{ blob: string; meta: DeletedMeta | null }>> {
const blobs = await listDeletedBlobs(this.app);

View file

@ -1409,6 +1409,7 @@ export class ConfirmModal extends Modal {
private message: string,
private confirmText: string,
private onChoose: (confirmed: boolean) => void,
private cancelText: string = "Cancel",
) { super(app); }
onOpen(): void {
this.modalEl?.addClass("stashpad-compact-modal"); // 0.76.18
@ -1423,7 +1424,7 @@ export class ConfirmModal extends Modal {
block.createDiv({ cls: "stashpad-confirm-line", text: line });
}
const row = this.contentEl.createDiv({ cls: "stashpad-modal-btns" });
const cancel = row.createEl("button", { text: "Cancel" });
const cancel = row.createEl("button", { text: this.cancelText });
cancel.onclick = () => { this.didChoose = true; this.close(); this.onChoose(false); };
const ok = row.createEl("button", { cls: "mod-cta", text: this.confirmText });
ok.onclick = () => { this.didChoose = true; this.close(); this.onChoose(true); };

View file

@ -9,14 +9,13 @@ import { FolderSuggest } from "./folder-suggest";
import type StashpadPlugin from "./main";
import { RESERVED_FRONTMATTER, type ViewMode } from "./types";
import { type SplitMode } from "./view-helpers";
import { LogModal, ColorPickerModal, NotificationHistoryModal, EncryptionPasswordModal, TypeToConfirmModal } from "./modals";
import { LogModal, ColorPickerModal, NotificationHistoryModal, EncryptionPasswordModal, TypeToConfirmModal, ConfirmModal } from "./modals";
import { CATEGORY_LABELS, type NotificationCategory } from "./notifications";
import { startHotkeyRecording, prettifyChord } from "./hotkey-recorder";
import { DEFAULT_STOPWORDS } from "./slug-service";
import { newId } from "./id-service";
import { formatDateTime } from "./format";
import { type EncryptionConfig, defaultEncryptionConfig } from "./encryption-service";
import { anyStashencOnDisk } from "./encryption-ops";
import { getActiveView } from "./active-view";
export interface ShortcutMap {
@ -597,7 +596,7 @@ export type SettingsTabId =
| "movingNotes" | "deleting" | "composerCopy" | "windowsTabs" | "notifications"
| "encryption" | "authorship" | "templates" | "jdindex" | "okf"
| "maintenance" | "diagnostics" | "misc" | "hotkeys";
export const SETTINGS_TABS: Array<{ id: SettingsTabId; label: string }> = [
export const SETTINGS_TABS: Array<{ id: SettingsTabId; label: string }> = ([
{ id: "foldersStorage", label: "Folders & storage" },
{ id: "importExport", label: "Import & export" },
{ id: "noteTitles", label: "Note titles" },
@ -617,7 +616,9 @@ export const SETTINGS_TABS: Array<{ id: SettingsTabId; label: string }> = [
{ id: "diagnostics", label: "Diagnostics" },
{ id: "misc", label: "Misc" },
{ id: "hotkeys", label: "Hotkeys" },
];
// 0.112.9: sections shown alphabetically by label. Display order only — the
// `id`-keyed itemsForTab dispatch is unaffected, and new tabs auto-sort in.
] as Array<{ id: SettingsTabId; label: string }>).sort((a, b) => a.label.localeCompare(b.label));
/** 0.94.0: a declarative sub-page that renders one of Stashpad's settings tabs
* via the existing imperative `renderTabContent`. Used by
@ -670,8 +671,19 @@ export class StashpadSettingTab extends PluginSettingTab {
* (and super.display() is a no-op), so render the SAME settings imperatively
* (one section per tab) no native search there, which is fine. Without this
* the Stashpad settings tab renders BLANK on older Obsidian. */
/** 0.112.11: IMPERATIVE fallback ONLY. On 1.13+ Obsidian ignores display()
* whenever getSettingDefinitions() returns items it renders declaratively
* via the base `update()` (a navigable page list + native search). So this
* runs only on pre-1.13 (no declarative API). It must NOT call super.display()
* on 1.13 that renders nothing (the source of the earlier blank tab) and on
* pre-1.13 it's a no-op. Just render every tab's items inline.
*
* CRITICAL: do NOT override `update()`. The base SettingTab.update() IS the
* 1.13 declarative renderer + search indexer; overriding it (as 0.112.8 did,
* based on stale-Obsidian testing where update() didn't exist) blanks the tab
* and forces this imperative fallback. The `this.update?.()` refresh calls
* resolve to the base update() on 1.13 (re-render) and no-op on pre-1.13. */
display(): void {
if (SettingPage) { super.display(); return; }
const { containerEl } = this;
containerEl.empty();
for (const t of SETTINGS_TABS) {
@ -861,7 +873,13 @@ export class StashpadSettingTab extends PluginSettingTab {
build: (s: Setting) => void,
aliases?: string[],
): SettingDefinitionItem {
return { name, desc, aliases, render: (s: Setting) => { s.setName(name).setDesc(desc); build(s); } };
// 0.112.5: try/catch so a single throwing control (e.g. a missing Obsidian
// API on an older build) degrades to a labelled row instead of aborting the
// ENTIRE declarative settings render and blanking every later section.
return { name, desc, aliases, render: (s: Setting) => {
s.setName(name).setDesc(desc);
try { build(s); } catch (e) { console.error(`[Stashpad] settings control "${name}" failed to render:`, e); }
} };
}
/** 0.94.3: a declarative item whose render builds a whole MULTI-element
@ -881,7 +899,7 @@ export class StashpadSettingTab extends PluginSettingTab {
host.empty();
host.removeClass("setting-item");
host.addClass("stashpad-settings-section");
render(host);
try { render(host); } catch (e) { console.error(`[Stashpad] settings section "${name}" failed to render:`, e); }
},
};
}
@ -1279,37 +1297,113 @@ export class StashpadSettingTab extends PluginSettingTab {
}
if (enc.amIMember()) {
new Setting(host).setName("Remove encryption").setDesc("Erases the key from this vault. Refused while any locked notes exist — decrypt everything first (locked notes have NO plaintext copy; losing the key loses them forever).").addButton((b) => {
b.setButtonText("Remove…").onClick(async () => {
// 0.98.23: HARD GUARD — locked blobs are the ONLY copy of their notes
// (lock permanently deletes the plaintext). Erasing the key with blobs
// still on disk = permanent data loss. Refuse until everything is
// decrypted; offer the vault-wide unlock as the way out.
// 0.98.42: scan the ADAPTER, not vault.getFiles — blobs are written
// via the adapter and can be invisible to the vault index for a
// while (and `_deleted/` / `.trash` blobs aren't indexed at all).
if (await anyStashencOnDisk(this.app)) {
new Notice(`Can't remove encryption: locked/encrypted-deleted notes still exist and would be lost forever. Run "Decrypt (unlock) ALL locked notes in the vault" and empty the encrypted trash first.`, 10000);
return;
}
new TypeToConfirmModal(this.app, {
title: "Remove encryption?",
body: "This erases the encryption key for this vault. Nothing is currently encrypted (locked notes are checked), so no content is lost — but you'll need to set a new password to encrypt later, and anything you had exported encrypted with this key stays locked to its passphrase.",
phrase: "REMOVE ENCRYPTION",
confirmText: "Remove encryption",
requirePassword: (pw) => enc.verifyPassword(pw),
onConfirm: async () => {
// Re-check at confirm time — a lock could have happened while the
// modal sat open (another device syncing, another window).
if (await anyStashencOnDisk(this.app)) {
new Notice("Locked notes appeared while this dialog was open — removal cancelled. Decrypt everything first.", 10000);
new Setting(host).setName("Remove encryption").setDesc("Erases the key for this vault so you can start fresh. If anything is still encrypted, you'll be offered to decrypt it (needs your password) or — if you've lost the password — permanently delete it. Then just type the confirmation phrase; no password needed.").addButton((b) => {
const runRemoval = async (): Promise<void> => {
// 0.112.0: authoritative-but-cheap state check (in-memory index + two
// folder listings of `_deleted/` and `.trash/`) instead of the old full
// recursive adapter walk that took minutes.
const state = await this.plugin.encryptionStateStrict();
if (state.live) {
// 0.112.7: live locked content has no plaintext copy. Offer to either
// DECRYPT everything (needs the password) or DELETE it forever (for a
// lost password), then re-enter the removal flow. Mirrors the trash.
const deleteAllLocked = (): void => {
new ConfirmModal(this.app, "Delete all locked content forever?",
"This PERMANENTLY destroys every locked note and folder — there is NO decrypted copy and NO recovery. Only do this if you've lost the password and want to start fresh.",
"Delete locked content forever",
async (del) => {
if (!del) return;
const n = await this.plugin.purgeAllLockedContent();
new Notice(`Deleted ${n} locked item${n === 1 ? "" : "s"}.`);
void runRemoval();
}).open();
};
const decryptAll = async (): Promise<void> => {
await this.plugin.unlockAllInVault(); // prompts for the password if locked
if ((await this.plugin.encryptionStateStrict()).live) {
new Notice("Some notes are still locked (decryption was cancelled or failed) — removal cancelled.", 10000);
return;
}
await enc.clear(); new Notice("Encryption removed."); this.update?.();
},
}).open();
});
b.setDestructive();
void runRemoval();
};
new ConfirmModal(this.app, "Encrypted notes are still locked",
"Some notes or folders are still encrypted (locked) and have no plaintext copy. Before removing encryption:\n\n• Decrypt everything — unlock them back to normal notes (needs your password).\n• Delete locked content — permanently destroy them (only if you've lost the password).",
"Decrypt everything",
(decrypt) => { if (decrypt) void decryptAll(); else deleteAllLocked(); },
"Delete locked content").open();
return;
}
// No password required to remove encryption. By the time we get here
// there is nothing recoverable to protect: live locked content already
// HARD-REFUSES removal (you must decrypt it first), and the trash has
// been explicitly decrypted or discarded. So the "REMOVE ENCRYPTION"
// phrase is the only gate — critically, this lets a user who has LOST
// the password (keychain empty/zeroed) still start fresh.
// trashConsented: the user already chose to decrypt or discard the
// encrypted trash; the confirm-time re-check uses it to abort only if
// NEW encrypted content appears mid-dialog.
const proceedToConfirm = (extraWarn: string, trashConsented: boolean): void => {
new TypeToConfirmModal(this.app, {
title: "Remove encryption?",
body: `This erases the encryption key for this vault. No locked notes/folders exist, so your live content is safe.${extraWarn} You'll need to set a new password to encrypt again later, and anything previously exported with this key stays locked to its passphrase.`,
phrase: "REMOVE ENCRYPTION",
confirmText: "Remove encryption",
onConfirm: async () => {
// Re-check authoritatively at confirm time — a lock or encrypt-
// delete could have synced in while the modal sat open.
const now = await this.plugin.encryptionStateStrict();
if (now.live) {
new Notice("Locked notes appeared while this dialog was open — removal cancelled. Decrypt everything first.", 10000);
return;
}
if (now.trash && !trashConsented) {
new Notice("Encrypted trash appeared while this dialog was open — removal cancelled. Re-open Remove encryption to handle it.", 10000);
return;
}
// Discard path: actually DELETE the encrypted-trash blobs now, so
// removal doesn't leave orphaned, forever-unreadable files behind.
// (Decrypt path already emptied `_deleted/`, so this is a no-op there.)
const leftover = await this.plugin.listDeletedTrash();
for (const it of leftover) await this.plugin.purgeDeletedAt(it.blob);
await enc.clear(); new Notice("Encryption removed."); this.update?.();
},
}).open();
};
if (state.trash) {
// Encrypted trash exists but no live locked content. Removing the key
// makes the trash permanently unreadable. Fork: Decrypt Trash (CTA,
// recommended — restores them to their folders) vs Discard trash
// (→ a second Cancel/Confirm step before anything is lost). Esc on
// this modal routes to the Discard confirm, which is cancelable.
const decryptThenRemove = async (): Promise<void> => {
const n = await this.plugin.restoreAllTrash();
// Authoritative re-check — restore removes blobs via raw adapter
// (no vault event), so confirm `_deleted/` is actually empty.
if ((await this.plugin.encryptionStateStrict()).trash) {
new Notice("Some encrypted-trash items couldn't be decrypted — removal cancelled. Check the trash tab and try again.", 10000);
return;
}
proceedToConfirm(n > 0 ? ` (${n} trash item${n === 1 ? "" : "s"} were decrypted back to their folders.)` : "", true);
};
const confirmDiscard = (): void => {
new ConfirmModal(this.app, "Discard encrypted trash?",
"The encrypted-trash items will be PERMANENTLY lost the moment the key is erased — there is no recovery. Continue?",
"Confirm",
(discard) => { if (discard) proceedToConfirm(" ⚠️ The encrypted trash is being permanently discarded.", true); }).open();
};
new ConfirmModal(this.app, "Encrypted notes in the trash",
"There are encrypted, deleted notes in the trash. Removing encryption makes them PERMANENTLY unreadable.\n\n• Decrypt Trash — restore them to their original folders first (recommended).\n• Discard trash — let them be permanently erased with the key.",
"Decrypt Trash",
(decrypt) => { if (decrypt) void decryptThenRemove(); else confirmDiscard(); },
"Discard trash").open();
return;
}
// Nothing encrypted at all → clean removal.
proceedToConfirm("", false);
};
b.setButtonText("Remove…").onClick(() => void runRemoval());
b.buttonEl.addClass("mod-warning");
});
}
@ -1324,7 +1418,7 @@ export class StashpadSettingTab extends PluginSettingTab {
new EncryptionPasswordModal(this.app, { mode: "setup", offerKeychain: false, kdfProbe, title: "Change shared password", intro: "Everyone who unlocks with the shared password will need the new one. Re-share it securely after changing.",
onSubmit: async ({ next }) => { if (!next) return "Enter a password."; try { await enc.setSharedPassword(next); } catch (e) { return (e as Error).message; } new Notice("Shared password updated."); this.update?.(); return null; } }).open();
}))
.addButton((b) => { b.setButtonText("Turn off").onClick(async () => { await enc.removeSharedPassword(); new Notice("Shared password turned off."); this.update?.(); }); b.setDestructive(); });
.addButton((b) => { b.setButtonText("Turn off").onClick(async () => { await enc.removeSharedPassword(); new Notice("Shared password turned off."); this.update?.(); }); b.buttonEl.addClass("mod-warning"); });
} else {
new Setting(host).setName("Shared password").setDesc("OFF — set one passphrase that everyone types to unlock (the simplest way to share). Anyone who knows it can unlock; turning it off later doesn't claw back copies already synced elsewhere.")
.addButton((b) => b.setButtonText("Set shared password…").onClick(() => {
@ -1354,7 +1448,7 @@ export class StashpadSettingTab extends PluginSettingTab {
await enc.removeMember(m.id);
new Notice(`Removed ${m.label}. (Not full revocation without rotating the key.)`);
this.update?.();
}); b.setDestructive(); });
}); b.buttonEl.addClass("mod-warning"); });
}
}
const reqs = enc.pendingJoinRequests();

View file

@ -1,6 +1,7 @@
import { ItemView, WorkspaceLeaf, moment, setIcon } from "obsidian";
import type StashpadPlugin from "./main";
import { STASHPAD_TRASH_VIEW_TYPE } from "./types";
import { ConfirmModal } from "./modals";
// Obsidian types `moment` as the namespace (not callable); cast to a callable.
const momentFn = moment as unknown as (...args: unknown[]) => { fromNow: () => string };
@ -24,6 +25,18 @@ export class StashpadTrashView extends ItemView {
// re-reads the store. Debounced via a microtask guard.
this.registerEvent(this.app.vault.on("create", (f) => { if (f.path.startsWith("_deleted/")) this.scheduleRender(); }));
this.registerEvent(this.app.vault.on("delete", (f) => { if (f.path.startsWith("_deleted/")) this.scheduleRender(); }));
// 0.112.4: Mod+A selects every trash item — but only while THIS tab is the
// active leaf, and not while typing in a field.
this.registerDomEvent(document, "keydown", (e) => {
if (this.app.workspace.activeLeaf !== this.leaf) return;
const tgt = e.target as HTMLElement | null;
if (tgt && (tgt.tagName === "INPUT" || tgt.tagName === "TEXTAREA" || tgt.isContentEditable)) return;
if ((e.metaKey || e.ctrlKey) && !e.altKey && (e.key === "a" || e.key === "A")) {
if (this.order.length === 0) return;
e.preventDefault();
this.selectAll();
}
});
await this.render();
}
@ -38,6 +51,11 @@ export class StashpadTrashView extends ItemView {
* each await (render is async + called from buttons/events two in flight
* would each empty-then-append, duplicating rows). */
private renderGen = 0;
/** 0.112.3: multi-select state. `selected` holds blob paths; `order` is the
* flat render order (for shift-range); `anchorIdx` is the last clicked row. */
private selected = new Set<string>();
private order: string[] = [];
private anchorIdx: number | null = null;
async render(): Promise<void> {
const gen = ++this.renderGen;
@ -65,7 +83,27 @@ export class StashpadTrashView extends ItemView {
const restoreAll = header.createEl("button", { cls: "stashpad-trash-restore", text: "Restore all" });
setIcon(restoreAll.createSpan({ cls: "stashpad-btn-icon" }), "rotate-ccw");
restoreAll.onclick = async () => { restoreAll.disabled = true; await this.plugin.restoreAllTrash(); await this.render(); };
restoreAll.onclick = async () => { restoreAll.disabled = true; await this.plugin.restoreAllTrash(); this.clearSelection(); await this.render(); };
// Drop any selected paths that no longer exist (restored/purged elsewhere).
const present = new Set(items.map((it) => it.blob));
for (const b of [...this.selected]) if (!present.has(b)) this.selected.delete(b);
// Selection action bar (only when something is selected).
if (this.selected.size > 0) {
const bar = root.createDiv({ cls: "stashpad-trash-selbar" });
bar.createSpan({ cls: "stashpad-trash-selcount", text: `${this.selected.size} selected` });
const restoreSel = bar.createEl("button", { cls: "stashpad-trash-restore", text: "Restore selected" });
restoreSel.onclick = async () => {
restoreSel.disabled = true;
for (const b of [...this.selected]) await this.plugin.restoreDeletedAt(b, { silent: true });
this.clearSelection(); await this.render();
};
const delSel = bar.createEl("button", { cls: "stashpad-trash-delete mod-warning", text: "Delete selected" });
delSel.onclick = () => this.confirmPurge([...this.selected]);
const clear = bar.createEl("button", { cls: "stashpad-trash-iconbtn", text: "Clear" });
clear.onclick = () => { this.clearSelection(); void this.render(); };
}
// Group by origin folder; obscured-title notes (no readable origin on disk)
// go under "Hidden" so we don't reveal where they came from.
@ -76,6 +114,9 @@ export class StashpadTrashView extends ItemView {
}
const keys = [...groups.keys()].sort((a, b) => (a === " hidden" ? 1 : b === " hidden" ? -1 : a.localeCompare(b)));
// Rebuild the flat render order for shift-range selection.
this.order = [];
for (const key of keys) {
const hidden = key === " hidden";
const group = root.createDiv({ cls: "stashpad-trash-group" });
@ -85,7 +126,15 @@ export class StashpadTrashView extends ItemView {
head.createSpan({ cls: "stashpad-trash-group-count", text: String(groups.get(key)!.length) });
for (const it of groups.get(key)!) {
const idx = this.order.length;
this.order.push(it.blob);
const row = group.createDiv({ cls: "stashpad-trash-row" });
if (this.selected.has(it.blob)) row.addClass("is-selected");
// Click the row body to (multi-)select; cmd/ctrl toggles, shift ranges.
row.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest("button")) return; // let buttons act
this.onRowClick(idx, it.blob, e);
});
const main = row.createDiv({ cls: "stashpad-trash-row-main" });
main.createSpan({ cls: "stashpad-trash-title", text: hidden ? "Locked note" : (it.meta?.title || "Locked note") });
const when = it.meta?.deletedAt ? `deleted ${momentFn(it.meta.deletedAt).fromNow()}` : "deleted";
@ -94,10 +143,55 @@ export class StashpadTrashView extends ItemView {
const btn = row.createEl("button", { cls: "stashpad-trash-restore", text: "Restore" });
setIcon(btn.createSpan({ cls: "stashpad-btn-icon" }), "rotate-ccw");
btn.onclick = async () => { btn.disabled = true; const ok = await this.plugin.restoreDeletedAt(it.blob); if (ok) await this.render(); else btn.disabled = false; };
const del = row.createEl("button", { cls: "stashpad-trash-delete", attr: { "aria-label": "Delete permanently" } });
setIcon(del, "trash-2");
del.onclick = () => this.confirmPurge([it.blob]);
}
}
}
private clearSelection(): void { this.selected.clear(); this.anchorIdx = null; }
/** Select every trash item (Mod+A). No-op when the list is empty. */
private selectAll(): void {
if (this.order.length === 0) return;
for (const b of this.order) this.selected.add(b);
this.anchorIdx = this.order.length - 1;
void this.render();
}
/** Selection click with cmd/ctrl (toggle), shift (range), plain (single). */
private onRowClick(idx: number, blob: string, e: MouseEvent): void {
if (e.shiftKey && this.anchorIdx !== null) {
const [a, b] = this.anchorIdx < idx ? [this.anchorIdx, idx] : [idx, this.anchorIdx];
this.selected.clear();
for (let i = a; i <= b; i++) this.selected.add(this.order[i]);
} else if (e.metaKey || e.ctrlKey) {
if (this.selected.has(blob)) this.selected.delete(blob); else this.selected.add(blob);
this.anchorIdx = idx;
} else {
if (this.selected.size === 1 && this.selected.has(blob)) this.selected.clear();
else { this.selected.clear(); this.selected.add(blob); }
this.anchorIdx = idx;
}
void this.render();
}
/** Confirm + permanently delete trash blobs (irreversible — no decrypt). */
private confirmPurge(blobs: string[]): void {
if (blobs.length === 0) return;
const n = blobs.length;
new ConfirmModal(this.app, n === 1 ? "Delete permanently?" : `Delete ${n} items permanently?`,
`${n === 1 ? "This encrypted item" : `These ${n} encrypted items`} will be PERMANENTLY deleted and CANNOT be restored — there is no decrypted copy. Continue?`,
"Delete forever",
async (ok) => {
if (!ok) return;
for (const b of blobs) await this.plugin.purgeDeletedAt(b);
this.clearSelection();
await this.render();
}).open();
}
async onClose(): Promise<void> { this.contentEl.empty(); }
}

View file

@ -2435,6 +2435,23 @@
.stashpad-trash-sub { font-size: var(--font-ui-smaller); color: var(--text-muted); }
.stashpad-trash-restore { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; font-size: var(--font-ui-smaller); padding: 3px 10px; cursor: pointer; --icon-size: 13px; }
.stashpad-trash-restore .svg-icon { width: var(--icon-size); height: var(--icon-size); }
/* 0.112.3: multi-select + permanent-delete in the encrypted-trash tab. */
.stashpad-trash-row { cursor: pointer; }
.stashpad-trash-row.is-selected { background: var(--background-modifier-hover); box-shadow: inset 0 0 0 1px var(--interactive-accent); }
.stashpad-trash-delete {
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
padding: 3px 8px; cursor: pointer; --icon-size: 14px;
background: transparent; border: none; box-shadow: none; color: var(--text-faint);
}
.stashpad-trash-delete:hover { color: var(--text-error); background: var(--background-modifier-error-hover); }
.stashpad-trash-delete .svg-icon { width: var(--icon-size); height: var(--icon-size); }
.stashpad-trash-selbar {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 6px 8px; margin-bottom: 8px; border-radius: 6px;
background: var(--background-modifier-hover);
}
.stashpad-trash-selcount { flex: 1 1 auto; font-size: var(--font-ui-smaller); color: var(--text-muted); }
.stashpad-trash-delete.mod-warning { color: var(--text-on-accent); background: var(--text-error); padding: 3px 10px; border-radius: 4px; font-size: var(--font-ui-smaller); }
/* 0.98.24: mobile ⋮ menu button on locked stubs (desktop uses right-click). */
.stashpad-locked-menu {
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;