mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.191.0: background parent tab on move-in, calmer rendering during sync, settings storage split
This commit is contained in:
parent
d791e8ef95
commit
cd7b0f00a9
8 changed files with 601 additions and 112 deletions
180
main.js
180
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
48
release-notes/0.191.0.md
Normal 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.
|
||||
98
src/main.ts
98
src/main.ts
|
|
@ -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
271
src/settings-store.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
103
src/view.ts
103
src/view.ts
|
|
@ -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 5–20/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})`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue