0.191.0: background parent tab on move-in, calmer rendering during sync, settings storage split

This commit is contained in:
GB 2026-07-20 08:54:07 -07:00
parent d791e8ef95
commit cd7b0f00a9
8 changed files with 601 additions and 112 deletions

180
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.188.1",
"version": "0.191.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.188.1",
"version": "0.191.0",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",

48
release-notes/0.191.0.md Normal file
View file

@ -0,0 +1,48 @@
# 0.191.0
Covers everything since 0.188.1 (0.189.0, 0.189.1, 0.190.0, 0.191.0).
## Moving notes (0.191.0)
- **Nesting a note into another opens that parent in a background tab.** When you
move a note into another note — by drag-and-drop or the in-list "Nest under…"
picker — its new home opens in a background Stashpad tab, so it's one click away
without pulling you out of what you were doing. Focus never leaves your current
tab.
It skips itself when there's nothing useful to open: a move to Home, or a parent
that's already open in a tab (so repeated nesting into the same parent doesn't pile
up duplicates). Also skipped when "Navigate into parent after moving a note IN" is
on, since that already takes you there.
New Behavior setting — **"Open the new parent in a background tab after moving a
note IN"** — on by default.
## Smoother behaviour during a sync (0.190.0)
- **The list no longer thrashes while a sync lands.** Stashpad re-rendered once per
file change, so a sync bringing in hundreds of files made the list flicker and
appear to render, stop, and render again. It now detects a burst of machine-driven
changes and settles once things go quiet, instead of re-rendering many times a
second. Ordinary editing is unchanged and just as responsive.
- **No more repeated "a newer build synced in" nudges.** That notice now waits for the
incoming version to settle, so a sync rewriting `manifest.json` mid-flight can't
spam it.
## Settings storage (0.189.0, 0.189.1)
- **Per-device churn moved out of `data.json`.** Reminder bookkeeping and unsent
composer drafts now live in a separate `history.json` beside it. Previously every
settings write rewrote one big file, so dismissing a reminder rewrote your hotkeys
and vice versa; the two are now independent.
Everything that needs to travel between your devices — preferences, hotkeys, folder
pins, encryption state — deliberately **stays** in `data.json`, which is the file
Obsidian Sync syncs.
Your existing settings migrate automatically on first launch, and a one-time backup
is written to `data.json.pre-split.bak` first. Nothing is lost.
One behaviour change worth knowing: reminder "already notified" state is now
per-device, so a reminder shown on your desktop no longer silences the same
reminder on your phone. Unsent composer drafts are likewise per-device now.

View file

@ -39,6 +39,7 @@ import { ImportService } from "./import-service";
import { ImportLog } from "./import-log";
import { perf } from "./perf";
import { RenderCacheStore } from "./render-cache-store";
import { SettingsStore, MOVED_KEYS } from "./settings-store";
/** 0.89.1: localStorage key set right before an update-triggered app reload so
* the next load knows to un-ghost the deferred Stashpad tabs. */
@ -1847,6 +1848,7 @@ export default class StashpadPlugin extends Plugin {
{ key: "prefixTimestampsOnCopy", label: "Prefix timestamps when copying" },
{ key: "useTemplatesFormat", label: "Use Templates plugin date/time formats" },
{ key: "autoNavOnMoveIn", label: "Auto-navigate into parent on move IN" },
{ key: "openParentTabOnMoveIn", label: "Open new parent in a background tab on move IN" },
{ key: "autoNavOnMoveOut", label: "Auto-navigate to destination on move OUT" },
{ key: "confirmCrossParentDrag", label: "Confirm cross-parent drag-and-drop" },
{ key: "confirmBulkDelete", label: "Confirm bulk deletes" },
@ -2148,9 +2150,11 @@ export default class StashpadPlugin extends Plugin {
// to our own data.json, then re-read + adopt the collision-protected keys and
// refresh the folder panel. Our own writes are skipped via the settingsRev
// guard in onExternalDataJsonChange.
const dataJsonPath = `${(this.manifest as any).dir.replace(/\/+$/, "")}/data.json`;
// 0.189.0: watch every file the settings store owns, not just data.json — the
// split files need the same "another window wrote this" adoption path.
const ownedPaths = new Set(this.store.watchPaths());
this.registerEvent((this.app.vault as any).on("raw", (changedPath: string) => {
if (changedPath === dataJsonPath) this.scheduleExternalDataJsonReload();
if (ownedPaths.has(changedPath)) this.scheduleExternalDataJsonReload();
}));
// 0.79.1: auto-import — any file appearing directly in a Stashpad
@ -2202,6 +2206,9 @@ export default class StashpadPlugin extends Plugin {
* the renderer on the cached old main.js, so the update "didn't take".
* Notifies once per detected on-disk version. */
private notifiedBuildVersion: string | null = null;
/** On-disk version seen on the PREVIOUS check must match the current one before
* we announce it, so a mid-sync manifest churn doesn't spam the notice. */
private pendingBuildVersion: string | null = null;
private async checkForSyncedBuild(): Promise<void> {
try {
const dir = (this.manifest as any).dir as string | undefined;
@ -2219,6 +2226,15 @@ export default class StashpadPlugin extends Plugin {
// focus forever. Silently ignore older/equal on-disk versions.
if (!this.isSemverGreater(onDisk, loaded)) return;
if (this.notifiedBuildVersion === onDisk) return;
// 0.190.0: require the on-disk version to hold STILL across two consecutive
// checks before nudging. Mid-sync, manifest.json can land, get rewritten, or
// flip versions repeatedly — announcing each transient state is what spammed
// the notice during a big sync. Waiting one interval means we only ever
// announce a build that actually settled.
if (this.pendingBuildVersion !== onDisk) {
this.pendingBuildVersion = onDisk;
return;
}
this.notifiedBuildVersion = onDisk;
this.notifications.show({
message: `A newer Stashpad build synced in (\`${loaded}\`\`${onDisk}\`). Reload the app to apply it.`,
@ -6411,7 +6427,12 @@ export default class StashpadPlugin extends Plugin {
}
async loadSettings(): Promise<void> {
const data = (await this.loadData()) ?? {};
// 0.189.0: merges data.json + history.json into the historic settings shape and
// performs the one-time split migration (backing data.json up first). Callers
// downstream see exactly the object they always did.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- same loose shape
// loadData() returned; the legacy-key migrations below index it dynamically.
const data = (((await this.store.loadAll()) ?? {}) as any);
// 0.137.3: collision guard — remember the on-disk write generation we
// loaded from, so a save can detect that another instance wrote since.
this.lastSeenSettingsRev = typeof data?.settingsRev === "number" ? data.settingsRev : 0;
@ -6736,6 +6757,10 @@ export default class StashpadPlugin extends Plugin {
private static readonly COLLISION_PROTECTED_KEYS = ["encryption", "lockedSubtrees", "folderEncPrefs", "archiveFolders", "folderPanelPinned", "folderPanelDownranked", "folderPanelHidden", "folderPanelPinnedGrouping"] as const;
private lastSeenSettingsRev = 0;
private settingsBaseline: Record<string, string | undefined> = {};
/** 0.189.0: persistence layer. data.json keeps everything that must sync; the
* per-device reminder/draft churn lives in history.json so a reminder firing no
* longer rewrites the whole settings blob. See docs/data-split-plan.md. */
private store = new SettingsStore(this);
private externalReloadDebounced = debounce(() => void this.onExternalDataJsonChange(), 600, false);
/** Coalesce a burst of `raw` data.json events (a sync often writes in chunks). */
@ -6747,21 +6772,33 @@ export default class StashpadPlugin extends Plugin {
* folder panel so synced pins/hides show without a reload. Skips our own writes
* via the settingsRev guard (our writes never raise diskRev above lastSeen). */
private async onExternalDataJsonChange(): Promise<void> {
let disk: Record<string, unknown> | null = null;
try { disk = (await this.loadData()) as Record<string, unknown> | null; } catch { return; }
if (!disk) return;
const diskRev = typeof disk.settingsRev === "number" ? (disk.settingsRev as number) : 0;
if (diskRev <= this.lastSeenSettingsRev) return; // our own write (or nothing newer)
const s = this.settings as unknown as Record<string, unknown>;
let panelChanged = false, anyChanged = false;
for (const k of StashpadPlugin.COLLISION_PROTECTED_KEYS) {
if (disk[k] === undefined) continue;
if (JSON.stringify(disk[k]) === JSON.stringify(s[k])) continue;
s[k] = disk[k];
anyChanged = true;
if (k.startsWith("folderPanel")) panelChanged = true;
// 0.189.0: adopt the SPLIT files first. They carry their own per-file revs, so a
// history.json write from another window must be picked up even when data.json
// is untouched — the old early-return on data.json's rev would have skipped it.
try {
const rep = await this.store.adoptExternal(s);
if (rep.any) anyChanged = true;
} catch { /* non-fatal: split files are device-local churn */ }
let disk: Record<string, unknown> | null = null;
try { disk = (await this.loadData()) as Record<string, unknown> | null; } catch { disk = null; }
const diskRev = typeof disk?.settingsRev === "number" ? (disk.settingsRev as number) : 0;
// Only walk data.json's protected keys when it actually moved on disk; our own
// writes never raise diskRev above lastSeen.
if (disk && diskRev > this.lastSeenSettingsRev) {
for (const k of StashpadPlugin.COLLISION_PROTECTED_KEYS) {
if (disk[k] === undefined) continue;
if (JSON.stringify(disk[k]) === JSON.stringify(s[k])) continue;
s[k] = disk[k];
anyChanged = true;
if (k.startsWith("folderPanel")) panelChanged = true;
}
this.lastSeenSettingsRev = diskRev;
}
this.lastSeenSettingsRev = diskRev;
if (anyChanged) {
setSettings(this.settings);
this.snapshotSettingsBaseline();
@ -6777,6 +6814,9 @@ export default class StashpadPlugin extends Plugin {
private async guardedSave(): Promise<void> {
let disk: Record<string, unknown> | null = null;
/** Set when the collision guard adopted a disk value forces the data.json
* write even if none of OUR core keys changed, so the merged result lands. */
let adoptedAny = false;
try { disk = (await this.loadData()) as Record<string, unknown> | null; } catch { /* first write / unreadable */ }
const diskRev = typeof disk?.settingsRev === "number" ? (disk.settingsRev as number) : 0;
// 0.140.2: adopt on CONTENT change, not just a higher rev. Two instances
@ -6796,6 +6836,7 @@ export default class StashpadPlugin extends Plugin {
}
}
if (adopted.length) {
adoptedAny = true;
console.warn(`[Stashpad] settings collision: on-disk protected keys differ from our baseline (disk rev ${diskRev}, we knew ${this.lastSeenSettingsRev}); adopted: ${adopted.join(", ")}.`);
new Notice(`Stashpad: another Obsidian instance (or a synced machine) changed this vault's settings. Merged instead of overwriting (${adopted.join(", ")}). If encryption behaves oddly, restart Obsidian.`, 10000);
setSettings(this.settings);
@ -6804,10 +6845,31 @@ export default class StashpadPlugin extends Plugin {
}
if (diskRev > this.lastSeenSettingsRev) this.lastSeenSettingsRev = diskRev;
}
const all0 = this.settings as unknown as Record<string, unknown>;
// Skip the data.json write entirely when only per-device churn changed — that's
// the other half of the split: a reminder firing must not rewrite your hotkeys.
const coreDirty = this.store.coreDirty(all0) || adoptedAny;
const rev = Math.max(diskRev, this.lastSeenSettingsRev) + 1;
(this.settings as unknown as Record<string, unknown>).settingsRev = rev;
await this.saveData(this.settings);
this.lastSeenSettingsRev = rev;
if (coreDirty) (this.settings as unknown as Record<string, unknown>).settingsRev = rev;
// 0.189.0: the per-device churn keys go to history.json (written only when they
// actually changed), and data.json is written WITHOUT them. The guarded-merge
// logic above still governs data.json's collision-protected keys.
const all = this.settings as unknown as Record<string, unknown>;
// Isolated: history.json is per-device churn, so a failure there must never
// block the (more important) data.json write that follows.
try {
await this.store.saveSplit(all);
} catch (e) {
console.warn("[Stashpad] history.json write failed — continuing with data.json.", e);
}
if (coreDirty) {
const core: Record<string, unknown> = {};
const moved = new Set<string>(MOVED_KEYS);
for (const [k, v] of Object.entries(all)) if (!moved.has(k)) core[k] = v;
await this.saveData(core);
this.lastSeenSettingsRev = rev;
this.store.markCoreSaved(all);
}
this.snapshotSettingsBaseline();
}

271
src/settings-store.ts Normal file
View file

@ -0,0 +1,271 @@
import type { Plugin } from "obsidian";
/** 0.189.0 persistence layer for plugin settings, split across several files.
*
* Everything used to live in one `data.json`: 94 keys, ~15 KB, rewritten in full by
* all 85 `saveSettings()` call sites. Ticking a task rewrote your hotkeys, and one
* stale write from a synced device could clobber every domain at once.
*
* This store keeps the IN-MEMORY settings object exactly as it was callers still
* read `plugin.settings.x` and call `saveSettings()` and changes only where each
* key lands on disk:
*
* data.json everything that must sync across devices (core prefs, hotkeys,
* folder-panel pins, encryption state). Obsidian-native, written
* via saveData, and still guarded by COLLISION_PROTECTED_KEYS.
* history.json per-device reminder/draft churn. Device-local by design.
*
* Two properties matter:
* 1. A save only rewrites files whose contents ACTUALLY changed (the churn win)
* so ticking a task no longer rewrites your hotkeys, and a reminder firing no
* longer rewrites your whole settings blob.
* 2. Each file is guarded independently against a concurrent writer (a second
* Obsidian window on the same vault): before writing we re-read the file, and
* for any key WE didn't change but disk did, we adopt disk's value instead of
* clobbering it the same rule as COLLISION_PROTECTED_KEYS, per file. */
/** Keys moved OUT of data.json. Anything not listed here stays in data.json.
*
* SCOPE IS DELIBERATELY NARROW: Obsidian Sync only syncs `<pluginDir>/data.json`
* (learned the hard way in 0.113.0 a relocated copy meant keybindings and pinned
* folders stopped propagating between devices). So anything that must sync
* hotkeys, folder-panel pins, encryption prefs, core toggles STAYS in data.json.
* Only per-device, machine-written churn moves out; that state is arguably wrong to
* sync anyway (one device's "already notified you about this reminder" shouldn't
* silence another's). */
export const SPLIT_FILES: Record<string, readonly string[]> = {
"history.json": ["notifiedDueKeys", "persistReminderLog", "lastSubmitted", "drafts"],
};
/** Flat list of every key that no longer belongs in data.json. */
export const MOVED_KEYS: readonly string[] = Object.values(SPLIT_FILES).flat();
/** Filename of the one-time pre-split backup. Never overwritten once created. */
export const BACKUP_FILE = "data.json.pre-split.bak";
type Bag = Record<string, unknown>;
/** Reported back to the caller so it can notify / refresh UI for adopted domains. */
export interface AdoptionReport {
/** file → keys whose on-disk value we adopted instead of overwriting. */
adopted: Record<string, string[]>;
/** True when anything at all was adopted. */
any: boolean;
}
export class SettingsStore {
/** key JSON of the value as we last read/wrote it. The arbiter for "did WE
* change this, or did disk?" same idea as the old settingsBaseline. */
private baseline: Record<string, string | undefined> = {};
/** file → last known rev (monotonic, bumped on each write of that file). */
private revs = new Map<string, number>();
constructor(private plugin: Plugin) {}
private dir(): string {
// manifest.dir is `.obsidian/plugins/stashpad` (or the user's configDir equivalent).
return this.plugin.manifest.dir ?? "";
}
private pathFor(file: string): string {
return `${this.dir()}/${file}`;
}
/** Every file this store owns, data.json included. */
fileNames(): string[] {
return ["data.json", ...Object.keys(SPLIT_FILES)];
}
/** Absolute-ish vault paths for all owned files — used by the external-change watcher. */
watchPaths(): string[] {
return this.fileNames().map((f) => this.pathFor(f));
}
private async readSplit(file: string): Promise<Bag | null> {
try {
const p = this.pathFor(file);
const adapter = this.plugin.app.vault.adapter;
if (!(await adapter.exists(p))) return null;
const raw = await adapter.read(p);
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === "object" ? (parsed as Bag) : null;
} catch {
// Unreadable/corrupt → treat as absent. The caller falls back to whatever is
// already in memory (data.json copy or defaults) and NEVER to empty.
return null;
}
}
private async writeSplit(file: string, payload: Bag, rev: number): Promise<void> {
const body = JSON.stringify({ ...payload, rev }, null, 2);
await this.plugin.app.vault.adapter.write(this.pathFor(file), body);
}
private static pick(src: Bag, keys: readonly string[]): Bag {
const out: Bag = {};
for (const k of keys) if (src[k] !== undefined) out[k] = src[k];
return out;
}
private snapshotKeys(src: Bag, keys: readonly string[]): void {
for (const k of keys) this.baseline[k] = JSON.stringify(src[k]);
}
/** Read every file and merge into ONE object with the historic settings shape.
* Split files win over data.json (data.json's copies are stale leftovers from
* before migration); a missing split file falls back to data.json's copy, so a
* deleted/unreadable file never reads as "user cleared these settings". */
async loadAll(): Promise<Bag> {
const base = ((await this.plugin.loadData()) as Bag | null) ?? {};
const merged: Bag = { ...base };
for (const [file, keys] of Object.entries(SPLIT_FILES)) {
const disk = await this.readSplit(file);
if (disk) {
for (const k of keys) if (disk[k] !== undefined) merged[k] = disk[k];
this.revs.set(file, typeof disk.rev === "number" ? disk.rev : 0);
} else {
this.revs.set(file, 0);
}
}
// One-time migration: data.json still carrying moved keys means this is a
// pre-split install (or a partial migration that was interrupted).
const leftovers = MOVED_KEYS.filter((k) => base[k] !== undefined);
if (leftovers.length) await this.migrate(base, merged);
this.snapshotKeys(merged, Object.keys(merged));
this.revs.set("data.json", typeof merged.settingsRev === "number" ? (merged.settingsRev as number) : 0);
return merged;
}
/** Move the split keys out of data.json, after taking a full one-time backup.
* Idempotent: once data.json no longer carries the keys this never runs again. */
private async migrate(base: Bag, merged: Bag): Promise<void> {
const adapter = this.plugin.app.vault.adapter;
try {
// 1. Full backup FIRST — nothing destructive happens before this lands.
const backupPath = this.pathFor(BACKUP_FILE);
if (!(await adapter.exists(backupPath))) {
await adapter.write(backupPath, JSON.stringify(base, null, 2));
}
// 2. Write each split file from the merged view.
for (const [file, keys] of Object.entries(SPLIT_FILES)) {
const payload = SettingsStore.pick(merged, keys);
// Don't create an empty file for a domain this vault never used.
if (Object.keys(payload).length === 0) continue;
await this.writeSplit(file, payload, (this.revs.get(file) ?? 0) + 1);
this.revs.set(file, (this.revs.get(file) ?? 0) + 1);
}
// 3. Strip the moved keys from data.json and rewrite it.
const stripped: Bag = {};
const moved = new Set(MOVED_KEYS);
for (const [k, v] of Object.entries(base)) if (!moved.has(k)) stripped[k] = v;
await this.plugin.saveData(stripped);
console.info(`[Stashpad] settings split: moved ${MOVED_KEYS.length} keys out of data.json (backup: ${BACKUP_FILE}).`);
} catch (e) {
// A failed migration is survivable: data.json still holds everything, and the
// merged in-memory view is correct, so the app runs normally and we retry next load.
console.error("[Stashpad] settings split migration failed — continuing on data.json.", e);
}
}
/** Write the SPLIT files (data.json stays the caller's job it still runs through
* the collision-protected guardedSave in main.ts). Files whose contents didn't
* change are skipped entirely; concurrent edits we'd otherwise clobber are adopted. */
async saveSplit(settings: Bag): Promise<AdoptionReport> {
const report: AdoptionReport = { adopted: {}, any: false };
for (const [file, keys] of Object.entries(SPLIT_FILES)) {
const changed = keys.some((k) => JSON.stringify(settings[k]) !== this.baseline[k]);
const disk = await this.readSplit(file);
// Collision guard: adopt any key WE didn't touch but disk did.
if (disk) {
const adopted: string[] = [];
for (const k of keys) {
const ours = JSON.stringify(settings[k]);
const theirs = JSON.stringify(disk[k]);
const oursChanged = ours !== this.baseline[k];
const diskChanged = theirs !== this.baseline[k];
if (!oursChanged && diskChanged && disk[k] !== undefined) {
settings[k] = disk[k];
adopted.push(k);
}
}
if (adopted.length) {
report.adopted[file] = adopted;
report.any = true;
}
const diskRev = typeof disk.rev === "number" ? disk.rev : 0;
if (diskRev > (this.revs.get(file) ?? 0)) this.revs.set(file, diskRev);
}
// Nothing of ours to persist for this domain → don't touch the file at all.
if (!changed && !report.adopted[file]) continue;
const payload = SettingsStore.pick(settings, keys);
if (Object.keys(payload).length === 0) continue; // never write an empty domain over a populated one
const rev = (this.revs.get(file) ?? 0) + 1;
await this.writeSplit(file, payload, rev);
this.revs.set(file, rev);
this.snapshotKeys(settings, keys);
}
return report;
}
/** Did any key that still lives in data.json change since we last read/wrote it?
* Lets the caller skip the data.json write entirely when only per-device churn
* moved so a reminder firing no longer rewrites your hotkeys. `settingsRev` is
* excluded because it's bookkeeping we bump *because* of a write, not a reason
* to write. */
coreDirty(settings: Bag): boolean {
const moved = new Set(MOVED_KEYS);
for (const k of Object.keys(settings)) {
if (moved.has(k) || k === "settingsRev") continue;
if (JSON.stringify(settings[k]) !== this.baseline[k]) return true;
}
return false;
}
/** Re-baseline the data.json-owned keys after the caller writes them. */
markCoreSaved(settings: Bag): void {
const moved = new Set(MOVED_KEYS);
this.snapshotKeys(settings, Object.keys(settings).filter((k) => !moved.has(k)));
}
/** An outside writer (other window / synced device) touched one of our files:
* re-read it and adopt any key that differs from our baseline and that we didn't
* change ourselves. Returns which keys changed so the caller can refresh UI. */
async adoptExternal(settings: Bag): Promise<AdoptionReport> {
const report: AdoptionReport = { adopted: {}, any: false };
for (const [file, keys] of Object.entries(SPLIT_FILES)) {
const disk = await this.readSplit(file);
if (!disk) continue;
const diskRev = typeof disk.rev === "number" ? disk.rev : 0;
if (diskRev <= (this.revs.get(file) ?? 0)) continue; // our own write, or nothing newer
const adopted: string[] = [];
for (const k of keys) {
if (disk[k] === undefined) continue;
if (JSON.stringify(disk[k]) === JSON.stringify(settings[k])) continue;
settings[k] = disk[k];
adopted.push(k);
}
this.revs.set(file, diskRev);
if (adopted.length) {
this.snapshotKeys(settings, keys);
report.adopted[file] = adopted;
report.any = true;
}
}
return report;
}
/** Re-baseline everything (after an external adoption or a forced reload). */
rebaseline(settings: Bag): void {
this.snapshotKeys(settings, Object.keys(settings));
}
}

View file

@ -519,6 +519,12 @@ export interface StashpadSettings {
* new parent; on, the view also drills into that parent so the user
* lands inside it. */
autoNavOnMoveIn: boolean;
/** 0.191.0: after moving/nesting a note INTO another note, open that destination
* parent in a BACKGROUND Stashpad tab so the note's new home is one click away
* without yanking you out of what you were doing. Distinct from autoNavOnMoveIn,
* which drills the CURRENT view into the parent (and steals your place). On by
* default; skipped when the destination is Home/root or already open in a tab. */
openParentTabOnMoveIn: boolean;
/** 0.73.14: when on, the row under the keyboard cursor temporarily
* un-clamps its body showing the full content as the user
* arrow-keys through the list. Moving the cursor away re-collapses
@ -690,6 +696,7 @@ export const DEFAULT_SETTINGS: StashpadSettings = {
notificationHistoryLimit: 5000,
notifiedDueKeys: [],
autoNavOnMoveIn: false,
openParentTabOnMoveIn: true,
autoNavOnMoveOut: false,
autoExpandCursorRow: false,
expandBodiesByDefault: false,
@ -1531,6 +1538,8 @@ export class StashpadSettingTab extends PluginSettingTab {
cats.movingNotes.push(toggle("Navigate into parent after moving a note IN", "When you move a note onto another note via the in-list move picker (drag-onto-sibling), automatically drill into the new parent so you can see the moved note in its new home. Off = stay focused where you were.",
() => this.plugin.settings.autoNavOnMoveIn, (v) => { this.plugin.settings.autoNavOnMoveIn = v; }, ["navigate", "move", "in"]));
cats.movingNotes.push(toggle("Open the new parent in a background tab after moving a note IN", "When you nest a note into another note, open that parent in a background Stashpad tab so its new home is one click away — without stealing focus from what you're doing. Skipped when the destination is Home or already open in a tab. On by default.",
() => this.plugin.settings.openParentTabOnMoveIn, (v) => { this.plugin.settings.openParentTabOnMoveIn = v; }, ["background", "tab", "move", "in", "parent"]));
cats.movingNotes.push(toggle("Navigate to destination after moving a note OUT", "When you outdent a note, move it via the cross-parent picker, or send it to Home, automatically drill into the destination parent. Off = stay focused where you were.",
() => this.plugin.settings.autoNavOnMoveOut, (v) => { this.plugin.settings.autoNavOnMoveOut = v; }, ["navigate", "move", "out"]));
cats.listDisplay.push(toggle("Double-click a note to open it", "Double-click (or double-tap on mobile) a note in the list to focus/open it — the same as pressing → or clicking the enter arrow. Single click still just selects. On by default.",

View file

@ -62,6 +62,21 @@ import type StashpadPlugin from "./main";
const IMG_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"]);
/** 0.190.0 sync-storm detection for the render debounce.
*
* More than RENDER_BURST_THRESHOLD change events inside RENDER_BURST_WINDOW_MS is
* machine-driven, not human: Obsidian debounces its own metadataCache while you
* type, so a person editing rarely exceeds ~2 change events a second. A sync
* landing a few hundred files easily sustains 520/s.
*
* Threshold tuned by measurement: a realistic storm arrives BURSTY, with gaps
* larger than the old 80ms debounce (~150ms apart), so the old code rendered once
* per event. At 4-per-second the detector engages a few events in and then
* coalesces the rest into a single settle render. */
const RENDER_CALM_MS = 900;
const RENDER_BURST_THRESHOLD = 5;
const RENDER_STORM_DELAY_MS = 700;
const VIEW_MODE_LABELS: Record<ViewMode, string> = {
nested: "Nested",
@ -166,7 +181,18 @@ export class StashpadView extends ItemView {
private slugDebouncers = new Map<string, ReturnType<typeof debounce>>();
private attachmentDebouncers = new Map<string, ReturnType<typeof debounce>>();
/** public: called by AuthorshipTracker (the host interface). */
debouncedRender: ReturnType<typeof debounce>;
debouncedRender: { (): void; cancel?: () => void };
/** 0.190.0: adaptive render coalescing. A fixed 80ms debounce re-fires every
* ~80ms for the ENTIRE duration of a sync storm (hundreds of files landing over
* many seconds), which is what made the list flicker and appear to render/stop/
* render. We count change events in a rolling window and, once it looks like a
* storm rather than typing, stretch the debounce so we render ONCE after things
* go quiet instead of ~12x/second. */
private renderBurstCount = 0;
private renderBurstWindowAt = 0;
private renderTimer: number | null = null;
/** True while change events are arriving faster than a human could cause. */
private renderStorm = false;
// Bulk-render suppression: while a bulk op runs (a long split paste,
// rebootstrap), metadata-driven re-renders are dropped so the list doesn't
// flicker per-note as files parse and fmSync writes recovery fields. One
@ -379,7 +405,36 @@ export class StashpadView extends ItemView {
const base = mode === "manual" ? this.order.getOrder(folder, parentId) : computeSortedIds(this, parentId, mode);
return this.hoistListPinned(parentId, base);
});
this.debouncedRender = debounce(() => { if (this.renderSuppressed()) return; this.render(); }, 80);
// 0.190.0: adaptive — 80ms when a human is driving, stretched to a quiet-period
// wait during a sync storm so the list settles once instead of thrashing.
const scheduleRender = (): void => {
const now = Date.now();
// Reset only after a genuine QUIET GAP. (A fixed rolling window was the first
// attempt and it silently defeated itself: the window boundary cleared the
// storm flag every second, dropping straight back to the snappy delay and
// re-rendering — measured as no improvement at all.)
if (now - this.renderBurstWindowAt > RENDER_CALM_MS) {
this.renderBurstCount = 0;
this.renderStorm = false;
}
this.renderBurstWindowAt = now;
this.renderBurstCount++;
if (this.renderBurstCount > RENDER_BURST_THRESHOLD) this.renderStorm = true;
const delay = this.renderStorm ? RENDER_STORM_DELAY_MS : 80;
if (this.renderTimer != null) window.clearTimeout(this.renderTimer);
this.renderTimer = window.setTimeout(() => {
this.renderTimer = null;
// NB: don't clear the storm flag here — the storm ends when events stop
// arriving (the calm check above), not when we happen to get a render in.
if (this.renderSuppressed()) return;
this.render();
}, delay);
};
this.debouncedRender = Object.assign(scheduleRender, {
cancel: () => {
if (this.renderTimer != null) { window.clearTimeout(this.renderTimer); this.renderTimer = null; }
},
});
this.authorship = new AuthorshipTracker(this);
this.dnd = new ViewDnD(this);
// 0.83.2: back the body render cache with the plugin's persisted store
@ -8045,6 +8100,10 @@ export class StashpadView extends ItemView {
if (await this.changeParent(t, target.id, { silentSuccess: true })) movedTargets.push(t);
}
this.notifyBatchMove(movedTargets, target.id, childCounts);
// 0.191.0: surface the new home in a BACKGROUND tab (setting-gated, on by
// default). Skipped when autoNavOnMoveIn is on — that already drills you into
// the parent, so a tab would just duplicate where you already are.
if (!this.plugin.settings.autoNavOnMoveIn) void this.openParentInBackgroundTab(target.id);
// 0.72.6: optional auto-navigate INTO the destination parent so
// the user follows their moved note. Skips the select-in-place
// flow below because navigateTo rebuilds the view for the new
@ -9371,6 +9430,41 @@ export class StashpadView extends ItemView {
// --- Open in new Stashpad tab ---
/** 0.191.0: open a Stashpad tab focused on `focusId` WITHOUT stealing focus
* used after nesting a note into another note so its new home is one click away
* while you keep working. Gated by the openParentTabOnMoveIn setting.
*
* No-ops when the destination is Home/root (nothing meaningful to open) or when a
* Stashpad tab is already focused there (so repeated nesting into the same parent
* doesn't pile up duplicate tabs). Focus is restored to whatever leaf was active
* before, which is what makes it a genuine background tab. */
async openParentInBackgroundTab(focusId: StashpadId): Promise<void> {
if (!this.plugin.settings.openParentTabOnMoveIn) return;
if (!focusId || focusId === ROOT_ID) return;
const ws = this.app.workspace;
// Already open on that parent? Leave it alone.
let existing = false;
ws.iterateAllLeaves((l) => {
const v: any = l.view;
if (v?.getViewType?.() === STASHPAD_VIEW_TYPE && v?.focusId === focusId && v?.noteFolder === this.noteFolder) existing = true;
});
if (existing) return;
const active = ws.activeLeaf;
try {
const leaf = ws.getLeaf("tab");
await leaf.setViewState({
type: STASHPAD_VIEW_TYPE,
active: false,
state: { focusId, timeFilter: this.timeFilter, folderOverride: this.folderOverride },
});
// getLeaf("tab") fronts the new tab; hand focus straight back so the move
// never interrupts the user's place.
if (active) ws.setActiveLeaf(active, { focus: true });
} catch (e) {
console.warn("[Stashpad] background parent tab failed", e);
}
}
private async openInNewStashpadTab(focusId: StashpadId): Promise<void> {
const ws = this.app.workspace;
const originLeaf = this.leaf;
@ -10365,6 +10459,11 @@ export class StashpadView extends ItemView {
}],
});
// 0.191.0: open the destination parent in a BACKGROUND tab so the moved note's
// new home is one click away without stealing focus (setting-gated, on by
// default; no-ops for a move to Home or when that parent is already open).
void this.openParentInBackgroundTab(targetParentId);
// Undo: revert each parent change AND restore the order snapshots for every affected parent.
this.plugin.getUndoStack(folder).push({
label: `Move + reorder (${sourceIds.length})`,