0.140.17: settings folder guard + hotkey conflict warning, search/picker/view polish

This commit is contained in:
Human 2026-07-02 22:49:31 -07:00
parent 6f8a8108a7
commit 3e4ffcbfd1
9 changed files with 205 additions and 93 deletions

104
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.140.15",
"version": "0.140.17",
"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.140.15",
"version": "0.140.17",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",

39
release-notes/0.140.17.md Normal file
View file

@ -0,0 +1,39 @@
# 0.140.17 — Settings safety, hotkey warnings, and a search/UI polish sweep
Two small follow-up releases closing out the review pass: a settings-folder
safety fix plus a hotkey conflict warning (0.140.16), and a batch of low-level
correctness/polish fixes across search, the note picker, and the view (0.140.17).
## 0.140.16 — Settings folder guard + hotkey conflict warning
- **Notes folder validation** — the "Stashpad notes folder" setting now checks
every segment of the path against the full set of reserved names (including
`_authors`, `_deleted`, `archive`, `trash`, `.stashpad`, `_attachments`,
`_processed`, and your import/export folders), so the notes folder can't be
pointed at — or nested inside — a reserved subfolder. It also validates and
saves on commit (blur or Enter) rather than on every keystroke, so a partially
typed folder name is never persisted.
- **Hotkey conflict warning** — binding a chord that's already assigned to a
different command now shows a heads-up notice ("also bound to …"; the first
match wins). The binding still applies — it's a warning, not a block.
## 0.140.17 — Search, picker, and view polish
- **Search highlighting** now highlights overlapping matches of the same term
fully (e.g. "aa" within "aaa").
- **Search filters** — a qualifier like `in:` / `before:` is no longer triggered
in the middle of a hyphenated word (so `check-in: desk` stays plain text), and
a matched filter is removed from the query by position, fixing a case where the
wrong slice could be stripped.
- **Note picker** — picking a note that was deleted while the picker was open now
shows a notice instead of acting on a stale reference.
- **Log viewer** — the "Reveal JSONL" / "Open in default app" buttons (which rely
on desktop-only APIs) are hidden on mobile.
- **Aggregate views** — opening an aggregate tab reliably reuses an existing tab
of the same kind instead of occasionally opening a duplicate.
- **Outdent** — outdenting several notes at once is now a single undo step with a
single summary notification, instead of one of each per note.
- **Popout windows** — list keyboard shortcuts now work when a Stashpad view is
in its own popout window.
- **Detail panel** — the tiny-window opacity popover cleans up its listeners when
the view is closed.

View file

@ -390,8 +390,11 @@ export class StashpadAggregateView extends ItemView {
* the same mode if one is open. */
export async function openAggregateView(plugin: StashpadPlugin, mode: AggregateMode): Promise<void> {
const { workspace } = plugin.app;
// Read mode from the LEAF's persisted view state, not the view instance — an
// inactive leaf may hold a DeferredView whose getState() lacks `mode`, so the
// old check missed a deferred existing tab and opened a duplicate. 0.140.17
const existing = workspace.getLeavesOfType(STASHPAD_AGGREGATE_VIEW_TYPE)
.find((l) => (l.view as StashpadAggregateView)?.getState?.().mode === mode);
.find((l) => ((l.getViewState()?.state as { mode?: string } | undefined)?.mode) === mode);
if (existing) { workspace.revealLeaf(existing); return; }
// 0.133.0: remember the tab we opened from so closing this aggregate view
// returns there, not to the tab on the right.

View file

@ -91,10 +91,14 @@ export class LogModal extends Modal {
this.filterSelEl.onchange = () => this.setTypeFilter(this.filterSelEl!.value || null);
this.refreshTypeFilter();
const revealBtn = toolbar.createEl("button", { text: "Reveal JSONL" });
revealBtn.onclick = () => this.shellAct("reveal");
const openBtn = toolbar.createEl("button", { text: "Open in default app" });
openBtn.onclick = () => this.shellAct("open");
// Reveal/Open shell out via electron, which doesn't exist on mobile — only
// offer them on desktop (the Copy button below covers mobile). 0.140.17
if (!Platform.isMobile) {
const revealBtn = toolbar.createEl("button", { text: "Reveal JSONL" });
revealBtn.onclick = () => this.shellAct("reveal");
const openBtn = toolbar.createEl("button", { text: "Open in default app" });
openBtn.onclick = () => this.shellAct("open");
}
const copyBtn = toolbar.createEl("button", { text: "Copy raw JSONL" });
let copyResetTimer: number | null = null;

View file

@ -1,4 +1,4 @@
import { App, FuzzySuggestModal, Platform, Scope, SuggestModal, TFile, moment, setIcon } from "obsidian";
import { App, FuzzySuggestModal, Notice, Platform, Scope, SuggestModal, TFile, moment, setIcon } from "obsidian";
import type { TreeIndex } from "./tree-index";
import type { TreeNode } from "./types";
import { ROOT_ID } from "./types";
@ -111,8 +111,13 @@ export function parseSearchQuery(query: string): ParsedQuery {
// Patterns:
// bracketed: key: [value with spaces]
// greedy: key: value (multi-word; stops at next key: or $)
const filterRe = /\b(in|before|after|on):\s*(?:\[([^\]]*)\]|([^]*?)(?=\s+(?:in|before|after|on):|$))/gi;
let remaining = raw;
// (?<![\w-]) not \b: a plain \b matches right after a hyphen, so free text like
// `check-in: desk` was hijacked into an `in:` filter. Require the qualifier to
// start at a real word boundary (not mid-hyphenated-word). 0.140.17
const filterRe = /(?<![\w-])(in|before|after|on):\s*(?:\[([^\]]*)\]|([^]*?)(?=\s+(?:in|before|after|on):|$))/gi;
// Blank matched spans by their exact index in `raw` (a `.replace(m[0])` hit the
// FIRST occurrence in a diverging copy, which could be the wrong slice). 0.140.17
const rawChars = raw.split("");
let m: RegExpExecArray | null;
while ((m = filterRe.exec(raw)) != null) {
const key = m[1].toLowerCase();
@ -155,9 +160,9 @@ export function parseSearchQuery(query: string): ParsedQuery {
}
// Only strip the slice once the filter actually applied — an unparseable
// date stays in the query as free text (see `applied` above). 0.140.14
if (applied) remaining = remaining.replace(m[0], " ");
if (applied) for (let k = m.index; k < m.index + m[0].length; k++) rawChars[k] = " ";
}
for (const tok of remaining.split(/\s+/)) {
for (const tok of rawChars.join("").split(/\s+/)) {
if (tok) out.text.push(tok.toLowerCase());
}
return out;
@ -839,7 +844,9 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
const ranges: Array<[number, number]> = [];
for (const t of cleanTokens) {
let i = 0;
while ((i = lower.indexOf(t, i)) !== -1) { ranges.push([i, i + t.length]); i += t.length; }
// Advance by 1, not t.length, so OVERLAPPING occurrences are all captured
// ("aa" in "aaa" → [0,2] and [1,3], merged below to [0,3]). 0.140.17
while ((i = lower.indexOf(t, i)) !== -1) { ranges.push([i, i + t.length]); i += 1; }
}
if (!ranges.length) { el.appendText(text); return; }
ranges.sort((a, b) => a[0] - b[0]);
@ -1762,7 +1769,7 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
// `in: [work] meeting notes` creates a note titled "meeting notes", not the
// literal query. Case preserved (unlike parseSearchQuery's tokens). 0.140.14
const rawTitle = (this as any).inputEl?.value ?? "";
const cleanTitle = rawTitle.replace(/\b(in|before|after|on):\s*(?:\[[^\]]*\]|[^]*?(?=\s+(?:in|before|after|on):|$))/gi, " ").replace(/\s+/g, " ").trim();
const cleanTitle = rawTitle.replace(/(?<![\w-])(in|before|after|on):\s*(?:\[[^\]]*\]|[^]*?(?=\s+(?:in|before|after|on):|$))/gi, " ").replace(/\s+/g, " ").trim();
this.opts.onCreate(cleanTitle || rawTitle);
return;
}
@ -1821,6 +1828,12 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
sub.open();
return;
}
// The candidate set is snapshotted on open; a note deleted while the picker
// was up hands the caller a stale TFile. Guard the local-note case. 0.140.17
if (item.node?.file && !this.app.vault.getAbstractFileByPath(item.node.file.path)) {
new Notice("That note no longer exists.");
return;
}
this.opts.onPick(item);
}
}

View file

@ -1349,22 +1349,33 @@ export class StashpadSettingTab extends PluginSettingTab {
cats.foldersStorage.push(this.renderDef("Stashpad notes folder", "Vault-relative folder where Stashpad stores its notes and attachments. Created on demand.", (s) => {
s.addText((t) => {
new FolderSuggest(this.app, t.inputEl);
t.setValue(this.plugin.settings.folder).setPlaceholder("Stashpad").onChange(async (v) => {
const cleaned = (v || "").trim().replace(/^\/+|\/+$/g, "") || DEFAULT_SETTINGS.folder;
const last = cleaned.split("/").filter(Boolean).pop() ?? "";
t.setValue(this.plugin.settings.folder).setPlaceholder("Stashpad");
// 0.140.16: validate + persist on COMMIT (blur/Enter), not per keystroke —
// the old onChange wrote settings.folder for every prefix ("S", "St", …),
// so other subsystems reading it mid-type (or a settings-close mid-type)
// saw a bogus folder. Also check EVERY path segment against the FULL
// reserved set (was last-segment-only and missing the Stashpad reserved
// subfolders), so the notes folder can't be nested inside archive/_deleted/etc.
const commit = async (): Promise<void> => {
const cleaned = (t.getValue() || "").trim().replace(/^\/+|\/+$/g, "") || DEFAULT_SETTINGS.folder;
const segs = cleaned.split("/").filter(Boolean);
const reserved = new Set([
this.plugin.settings.importDropFolder,
this.plugin.settings.exportFolder,
"_attachments",
"_processed",
// Stashpad-reserved subfolders — a notes folder must not BE or live under one.
"_attachments", "_processed", "_authors", "_deleted", "archive", "trash", ".stashpad",
].map((x) => (x ?? "").trim().replace(/^\/+|\/+$/g, "")).filter(Boolean));
if (reserved.has(last)) {
if (segs.some((seg) => reserved.has(seg))) {
new Notice(`"${cleaned}" uses a reserved Stashpad subfolder name. Pick something else.`);
t.setValue(this.plugin.settings.folder); // restore the last valid value
return;
}
if (cleaned === this.plugin.settings.folder) return;
this.plugin.settings.folder = cleaned;
await set();
});
};
t.inputEl.addEventListener("blur", () => void commit());
t.inputEl.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter") void commit(); });
});
}, ["folder", "path", "location", "notes"]));
@ -3105,6 +3116,15 @@ export class StashpadSettingTab extends PluginSettingTab {
const slotDefault = which === "primary" ? meta.defaultPrimary : (meta.defaultSecondary ?? "");
input.onclick = () => {
startHotkeyRecording(input, async (chord) => {
// Non-blocking conflict warning: if this chord is already bound to a
// DIFFERENT command, whichever matchBinding runs first in view.ts wins,
// so a silent double-bind is confusing. Still allow it. 0.140.16
const clash = COMMAND_META.find((m) => {
if (m.id === meta.id) return false;
const b = this.plugin.settings.bindings[m.id];
return !!b && (b.primary === chord || b.secondary === chord);
});
if (clash) new Notice(`Note: ${prettifyChord(chord)} is also bound to "${clash.label}". Both will trigger; the first match wins.`, 6000);
this.plugin.settings.bindings[meta.id][which] = chord;
input.value = prettifyChord(chord);
syncSize();

View file

@ -591,7 +591,12 @@ export class StashpadView extends ItemView {
});
(this.app.vault as any).on("modify", this.onFileModify);
(this.app.vault as any).on("create", this.onFileCreate);
window.addEventListener("keydown", this.onDocKeyDown, true);
// Bind to the leaf's OWN window — a popout/tiny-window leaf lives in its own
// Electron window with its own document, so keydowns there never reach the
// main window's listener and list-level shortcuts were dead. Equals `window`
// for normal leaves. Captured so onClose removes it from the same one. 0.140.17
this.keydownWindow = (this.containerEl?.ownerDocument?.defaultView ?? window) as Window;
this.keydownWindow.addEventListener("keydown", this.onDocKeyDown, true);
this.loadConfig();
// 0.71.36: bootstrap can throw "Folder already exists" when the
// vault state races our cache check on tab open/close. Swallow
@ -864,11 +869,13 @@ export class StashpadView extends ItemView {
// Cancel any pending debounced render so it can't fire post-close (the
// render() isConnected guard also catches it — belt and suspenders). 0.140.9
(this.debouncedRender as any)?.cancel?.();
// Tear down the tiny-opacity popover's document listeners if it's still open. 0.140.17
this.tinyOpacityClose?.();
this.detachTreeHook?.();
this.detachSettings?.();
(this.app.vault as any).off("modify", this.onFileModify);
(this.app.vault as any).off("create", this.onFileCreate);
window.removeEventListener("keydown", this.onDocKeyDown, true);
this.keydownWindow.removeEventListener("keydown", this.onDocKeyDown, true);
this.listResizeObserver?.disconnect();
this.listResizeObserver = null;
this.stickyRowObserver?.disconnect();
@ -4337,6 +4344,7 @@ export class StashpadView extends ItemView {
/** 0.77.0-feat: handle to the open opacity popover so a second click
* (or click-outside) closes it. */
private tinyOpacityPopover: HTMLElement | null = null;
private tinyOpacityClose: (() => void) | null = null;
private toggleTinyOpacityPopover(anchor: HTMLElement): void {
if (this.tinyOpacityPopover) {
this.tinyOpacityPopover.remove();
@ -4378,6 +4386,7 @@ export class StashpadView extends ItemView {
const close = () => {
pop.remove();
this.tinyOpacityPopover = null;
this.tinyOpacityClose = null;
document.removeEventListener("mousedown", onDoc, true);
document.removeEventListener("keydown", onKey, true);
};
@ -4386,6 +4395,9 @@ export class StashpadView extends ItemView {
document.addEventListener("keydown", onKey, true);
}, 0);
this.tinyOpacityPopover = pop;
// Expose close() so onClose can tear down the document listeners if the view
// is closed while this popover is still open. 0.140.17
this.tinyOpacityClose = close;
slider.focus();
}
@ -6204,6 +6216,9 @@ export class StashpadView extends ItemView {
// --- Document-level keyboard ---
/** The window the keydown listener is bound to the leaf's own (popout-aware).
* Defaults to the main window; set in onOpen. 0.140.17 */
private keydownWindow: Window = window;
private onDocKeyDown = (e: KeyboardEvent): void => {
if (!this.viewRoot.isConnected) return;
// Run when our Stashpad leaf is the active one, regardless of where focus
@ -7317,35 +7332,53 @@ export class StashpadView extends ItemView {
if (!targets.length) return;
const moved: TreeNode[] = [];
const skipped: string[] = [];
// 0.140.17: capture each move so the whole outdent is ONE undo entry (was N),
// and suppress the per-move toast (silentSuccess) so a clean outdent shows a
// single summary toast instead of N "Reparented" ones.
const priorParents: { id: StashpadId; path: string; oldParent: StashpadId | null; newParent: StashpadId }[] = [];
for (const t of targets) {
const parent = t.parent ? this.tree.get(t.parent) : null;
if (!parent || parent.id === ROOT_ID) { skipped.push(t.id); continue; }
const grandparent = parent.parent ?? ROOT_ID;
await this.changeParent(t, grandparent);
priorParents.push({ id: t.id, path: t.file?.path ?? "", oldParent: t.parent ?? null, newParent: grandparent });
await this.changeParent(t, grandparent, { record: false, silentSuccess: true });
moved.push(t);
}
if (moved.length === 0) {
new Notice(skipped.length ? "Already at the top level." : "Nothing to outdent.");
return;
}
const outdentFolder = this.noteFolder;
this.plugin.getUndoStack(outdentFolder).push({
label: `Outdent (${moved.length})`,
undo: async () => {
for (const p of priorParents) {
const f = this.fileForNote(p.id, p.path);
if (f) await this.app.fileManager.processFrontMatter(f, (fm) => { fm.parent = p.oldParent ?? ROOT_ID; });
}
this.tree.rebuild(outdentFolder); this.render();
},
redo: async () => {
for (const p of priorParents) {
const f = this.fileForNote(p.id, p.path);
if (f) await this.app.fileManager.processFrontMatter(f, (fm) => { fm.parent = p.newParent; });
}
this.tree.rebuild(outdentFolder); this.render();
},
});
this.render();
if (skipped.length) {
// 0.97.x fix: `moved` already holds the outdented TreeNodes — the old code
// ran them back through tree.get() (which wants an id), getting undefined
// for every entry so the message listed nothing; and passed node objects
// as affectedIds. Use the nodes directly + map to ids.
this.plugin.notifications.show({
message: this.bulkActionMessage({
verb: "Outdented",
nodes: moved,
suffix: skipped.length ? `(${skipped.length} already at root)` : undefined,
}),
kind: "success",
category: "move",
affectedIds: moved.map((n) => n.id),
folder: this.noteFolder,
});
}
// One summary toast, always (not only when some were skipped).
this.plugin.notifications.show({
message: this.bulkActionMessage({
verb: "Outdented",
nodes: moved,
suffix: skipped.length ? `(${skipped.length} already at root)` : undefined,
}),
kind: "success",
category: "move",
affectedIds: moved.map((n) => n.id),
folder: this.noteFolder,
});
// 0.72.6 / 0.73.8: optionally follow the outdented note(s) into
// their new (shared) grandparent. Works only when every moved
// target shares the same destination; mixed-source outdents