mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.140.17: settings folder guard + hotkey conflict warning, search/picker/view polish
This commit is contained in:
parent
6f8a8108a7
commit
3e4ffcbfd1
9 changed files with 205 additions and 93 deletions
104
main.js
104
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
39
release-notes/0.140.17.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
73
src/view.ts
73
src/view.ts
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue