0.146.0: notification jump buttons for aggregate views + action-log capture for lock/unlock/archive/restore

This commit is contained in:
Human 2026-07-05 09:00:42 -07:00
parent c65aede818
commit a289dbe399
9 changed files with 120 additions and 85 deletions

144
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.145.0",
"version": "0.146.0",
"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.145.0",
"version": "0.146.0",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",

20
release-notes/0.146.0.md Normal file
View file

@ -0,0 +1,20 @@
# 0.146.0
## Notification jump buttons
- Notifications for actions whose result lands in an **aggregate view** now carry a
one-click button to jump straight there:
- **Locking** notes (selection or a whole folder) → **"All encrypted"**.
- **Archiving** notes (plaintext or encrypted, single or bulk) → **"All archived"**.
- **Importing** a `.stash` bundle or folder → **"Go to folder"** (next to Undo).
- Deleting to trash, restoring from trash, and cross-Stashpad moves already carried
their jump buttons; this fills in the ones that were missing so every "where did it
go?" notification can take you there.
## Action-log capture for the encryption / archive / trash lifecycle
- The per-folder action log now records **lock**, **unlock**, **archive**, and
**restore** events — previously these were only shown as transient notifications and
left no durable record. They appear in the log history with readable descriptions.
- A default delete that goes to Stashpad's own trash is now marked **"→ Trash"** in the
log, distinguishing it from a delete sent to Obsidian's native trash.

View file

@ -552,7 +552,10 @@ export class ImportService {
duration: 0,
folder,
affectedPaths: records.flatMap((r) => this.recordNotePaths(r)),
actions: [{ label: "Undo import", onClick: () => void doUndo() }],
actions: [
...(folder ? [{ label: "Go to folder", onClick: () => void this.plugin.activateViewForFolder(folder) }] : []),
{ label: "Undo import", onClick: () => void doUndo() },
],
});
// Also put it on the folder's undo stack so Mod+Z / the undo command
// reverses the import. Redo is a no-op (re-importing would need the

View file

@ -3788,7 +3788,7 @@ export default class StashpadPlugin extends Plugin {
if (await this.lockNoteSubtree(cleaned, id, prevSibling, { silent: true })) count++;
}
prog?.hide();
if (count > 0) this.notifications.show({ message: `Locked ${count} note${count === 1 ? "" : "s"} in “${cleaned.split("/").pop()}”.`, kind: "success", category: "system", folder: cleaned });
if (count > 0) this.notifications.show({ message: `Locked ${count} note${count === 1 ? "" : "s"} in “${cleaned.split("/").pop()}”.`, kind: "success", category: "system", folder: cleaned, actions: [{ label: "All encrypted", onClick: () => void openAggregateView(this, "encrypted") }] });
return count;
}
@ -4098,6 +4098,7 @@ export default class StashpadPlugin extends Plugin {
}
const r = await restorePlaintextDeleted(this.app, blobPath, existing);
this.pendingEncBlobs.delete(blobPath);
try { await this.newLog().append({ type: "restore", id: meta?.rootId || ROOT_ID, payload: { to: r.restoredTo, from: "trash", encrypted: false } }); } catch { /* log best-effort */ }
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, actions: [{ label: "Go to folder", onClick: () => void this.activateViewForFolder(r.restoredTo) }] });
return true;
} catch (e) {
@ -4149,6 +4150,7 @@ export default class StashpadPlugin extends Plugin {
}
const r = await restoreDeleted(this.app, blobPath, dek, existing);
this.pendingEncBlobs.delete(blobPath);
try { await this.newLog().append({ type: "restore", id: meta?.rootId || ROOT_ID, payload: { to: r.restoredTo, from: "trash", encrypted: true } }); } catch { /* log best-effort */ }
// 0.138.0: restore-from-trash counts as an unlock (user decision) — the
// note WAS encrypted and is plaintext again. Sidecar carries the identity.
if (meta?.rootId) {
@ -5040,7 +5042,7 @@ export default class StashpadPlugin extends Plugin {
for (const r of roots) {
if (await this.lockNoteSubtree(cleaned, r.id, null, { silent: true })) count++;
}
if (count > 0) this.notifications.show({ message: `Archived (encrypted) ${count} note${count === 1 ? "" : "s"} moved into “${cleaned.split("/").pop()}”.`, kind: "success", category: "system", folder: cleaned });
if (count > 0) this.notifications.show({ message: `Archived (encrypted) ${count} note${count === 1 ? "" : "s"} moved into “${cleaned.split("/").pop()}”.`, kind: "success", category: "system", folder: cleaned, actions: [{ label: "All archived", onClick: () => void openAggregateView(this, "archived") }] });
}
/** Open a fresh Stashpad tab focused on a specific folder via the

View file

@ -319,7 +319,8 @@ export class LogModal extends Modal {
case "delete": {
const atts = Array.isArray(p.attachmentsRemoved) ? p.attachmentsRemoved.length : 0;
const merged = p.mergedInto ? ` (merged into ${p.mergedInto})` : "";
return `Deleted ${ev.id}${merged}${atts ? ` — also removed ${atts} attachment${atts === 1 ? "" : "s"}` : ""}`;
const toTrash = p.to === "trash" ? " → Trash" : "";
return `Deleted ${ev.id}${toTrash}${merged}${atts ? ` — also removed ${atts} attachment${atts === 1 ? "" : "s"}` : ""}`;
}
case "missing": return `Missing: ${p.lastPath ?? ev.id}`;
case "parent_change": {
@ -359,6 +360,10 @@ export class LogModal extends Modal {
case "attachment_remove": return `Removed attachment ${p.path ?? ev.id}`;
case "palette_color_add": return `Added palette color ${p.color ?? ev.id}`;
case "palette_color_remove": return `Removed palette color ${p.color ?? ev.id}`;
case "lock": return `Locked (encrypted) ${ev.id}`;
case "unlock": return `Unlocked ${ev.id}`;
case "archive": return `Archived ${ev.id}${p.to ? `${p.to}` : ""}${p.encrypted === false ? " (plaintext)" : ""}`;
case "restore": return `Restored ${ev.id}${p.to ? `${p.to}` : ""} from trash`;
default: return JSON.stringify(p);
}
}

View file

@ -289,7 +289,10 @@ export type LogEventType =
| "palette_color_add" | "palette_color_remove"
// 0.136.0: one-time move of legacy dedicated-archive contents into the
// folder's own archive/ subfolder (per-folder archive overhaul).
| "archive_migration";
| "archive_migration"
// 0.146.0: encryption / archive / trash lifecycle — previously surfaced only
// as notifications; now also recorded in the per-folder action log.
| "lock" | "unlock" | "archive" | "restore";
export interface LogEvent {
ts: string;

View file

@ -27,6 +27,7 @@ import { setActiveView, clearActiveView } from "./active-view";
import { BreadcrumbLevelsModal, type BreadcrumbLevel, ColorPickerModal, ConfirmDeleteModal, ConfirmModal, DueDatePickerModal, SplitNoteModal } from "./modals";
import { ComposerAutocomplete } from "./composer-autocomplete";
import { matchBinding, humanCombo } from "./view-keys";
import { openAggregateView } from "./aggregate-view";
import { AuthorshipTracker } from "./authorship-tracker";
import { ViewDnD } from "./view-dnd";
import { NoteBodyRenderer } from "./note-body-renderer";
@ -6692,13 +6693,13 @@ export class StashpadView extends ItemView {
const prevSibling = idx > 0 ? order[idx - 1] : null;
// Silent per-item; one summary toast below (a batch shouldn't spam).
const r = await this.plugin.lockNoteSubtree(this.noteFolder, t.id, prevSibling, { silent: true });
if (r) locked++;
if (r) { locked++; await this.log.append({ type: "lock", id: t.id }); }
}
if (locked > 0) {
this.selection.clear();
this.lastSelected = null;
this.render();
this.plugin.notifications.show({ message: `Locked ${locked} stash${locked === 1 ? "" : "es"}.`, kind: "success", category: "system", folder: this.noteFolder });
this.plugin.notifications.show({ message: `Locked ${locked} stash${locked === 1 ? "" : "es"}.`, kind: "success", category: "system", folder: this.noteFolder, actions: [{ label: "All encrypted", onClick: () => void openAggregateView(this.plugin, "encrypted") }] });
}
}
@ -6754,8 +6755,9 @@ export class StashpadView extends ItemView {
try { await this.app.fileManager.renameFile(f, to); moves.push({ from: fromPath, to }); } catch (e) { console.warn("[Stashpad] plaintext archive move failed", fromPath, e); }
}
this.selection.clear(); this.lastSelected = null; this.tree.rebuild(src); this.render();
if (moves.length > 0) for (const rt of roots0) await this.log.append({ type: "archive", id: rt.id, payload: { to: cleanDest, encrypted: false } });
const nm = cleanDest.split("/").pop() || cleanDest;
this.plugin.notifications.show({ message: `Moved ${moves.length} note${moves.length === 1 ? "" : "s"} → plaintext archive “${nm}” (de-indexed, not encrypted). Undo to bring ${moves.length === 1 ? "it" : "them"} back.`, kind: "success", category: "system", folder: src });
this.plugin.notifications.show({ message: `Moved ${moves.length} note${moves.length === 1 ? "" : "s"} → plaintext archive “${nm}” (de-indexed, not encrypted). Undo to bring ${moves.length === 1 ? "it" : "them"} back.`, kind: "success", category: "system", folder: src, actions: [{ label: "All archived", onClick: () => void openAggregateView(this.plugin, "archived") }] });
this.plugin.getUndoStack(src).push({
label: `Archive (plaintext, ${roots0.length})`,
undo: async () => { for (const m of moves) { const fl = this.app.vault.getAbstractFileByPath(m.to); if (fl instanceof TFile) { try { await this.app.fileManager.renameFile(fl, m.from); } catch (e) { console.warn("[Stashpad] plaintext archive undo failed", m.to, e); } } } this.tree.rebuild(src); this.render(); },
@ -6784,12 +6786,12 @@ export class StashpadView extends ItemView {
const idx = order.indexOf(t.id);
const prevSibling = idx > 0 ? order[idx - 1] : null;
const r = await this.plugin.lockNoteSubtree(src, t.id, prevSibling, { silent: true, blobFolder: dest });
if (r) blobs.push(r.blobPath);
if (r) { blobs.push(r.blobPath); await this.log.append({ type: "archive", id: t.id, payload: { to: dest, encrypted: true } }); }
}
if (blobs.length === 0) return;
this.selection.clear(); this.lastSelected = null; this.tree.rebuild(src); this.render();
const name = dest.split("/").pop() || dest;
this.plugin.notifications.show({ message: `Archived ${blobs.length} note${blobs.length === 1 ? "" : "s"} → “${name}”. Undo to bring ${blobs.length === 1 ? "it" : "them"} back.`, kind: "success", category: "system", folder: src });
this.plugin.notifications.show({ message: `Archived ${blobs.length} note${blobs.length === 1 ? "" : "s"} → “${name}”. Undo to bring ${blobs.length === 1 ? "it" : "them"} back.`, kind: "success", category: "system", folder: src, actions: [{ label: "All archived", onClick: () => void openAggregateView(this.plugin, "archived") }] });
this.plugin.getUndoStack(src).push({
label: `Archive (${blobs.length})`,
undo: async () => {
@ -6911,7 +6913,7 @@ export class StashpadView extends ItemView {
const folder = this.noteFolder;
const rootIds = roots.map((r) => r.id);
let blobs: string[] = [];
for (const id of rootIds) { const b = await this.plugin.plaintextDeleteSubtree(folder, id); if (b) blobs.push(b); }
for (const id of rootIds) { const b = await this.plugin.plaintextDeleteSubtree(folder, id); if (b) { blobs.push(b); await this.log.append({ type: "delete", id, payload: { to: "trash", bundle: b } }); } }
if (blobs.length === 0) return;
this.selection.clear(); this.lastSelected = null; this.tree.rebuild(folder); this.render();
this.plugin.notifications.show({ message: `Deleted ${blobs.length} note${blobs.length === 1 ? "" : "s"} → Trash. Undo to bring ${blobs.length === 1 ? "it" : "them"} back.`, kind: "success", category: "system", folder, actions: [{ label: "Open Trash", onClick: () => this.plugin.openEncryptedTrash() }] });