0.121.13: settings overhaul — tabs, polish, icon search, hotkey tri-state, date-format fix

This commit is contained in:
Human 2026-06-23 08:39:21 -07:00
parent 0510541589
commit 727b78891e
8 changed files with 480 additions and 269 deletions

168
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.121.1",
"version": "0.121.13",
"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.121.1",
"version": "0.121.13",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",

51
release-notes/0.121.13.md Normal file
View file

@ -0,0 +1,51 @@
# 0.121.13 — Settings overhaul: clearer structure, polish, and a date-format fix
A settings-focused release: the tabs are reorganized, long-standing alignment
and padding issues are fixed, the per-folder icon picker becomes searchable, the
hotkey UI is reworked, and a date-format bug is fixed.
## Reorganized settings tabs
- **Behavior** — the six toggle-only sections (List & Display, Moving Notes,
Composer & Copying, Deleting, Windows & Tabs, Misc) are now one **🎛️ Behavior**
tab, each kept as a labelled sub-heading.
- **Organization Systems****JD Index** and **Open Knowledge Format (OKF)** are
folded into one **🗂️ Organization Systems** tab.
- **Note Titles** is no longer its own tab — the "Slug stop-words" setting moves
to the end of **Folders & Storage** under a "Note Titles" heading.
## Per-folder icon picker is now searchable
- The **Folder tab icon** box is type-ahead: start typing and matching Lucide
icons appear with live previews — no need to know icon ids by heart.
## Date & time
- **Fix:** with "Use Templates plugin date/time formats" OFF, the notes-list
timestamps now follow the **Date display format** dropdown (and timezone) like
the Tasks/detail panels do — previously the list ignored the dropdown and was
hardcoded to `YYYY.MM.DD`.
- The Dates & Time section is reordered (display format first, then the Templates
toggle) with clearer descriptions.
## Hotkeys
- The two key slots now **stack vertically** with the active-binding control on
the left, so the binding inputs sit at the right edge.
- The old L/R pill + "Use both" checkbox are replaced by a single
**Left | Both | Right** segmented control (disabled until both slots are bound).
- Chord slots are styled as compact rounded key-chips; the clear/revert buttons
stack on mobile.
## Layout & padding polish
- Settings sections that previously sat flush against the modal edge are now
inset to align with the rest: the Cross-Stashpad Search Scope and Folder Panel
Placement lists, the JD Index description, the live date sample, and the
"Folders you've worked in" / Known Authors lists (the authored-folders list
also gains a heading).
- Each settings page gets breathing room at the bottom; folded-tab sub-headings
get top padding so the first one isn't cramped under the page title.
- The encryption no-recovery advice banner gets more padding.
- The JD "Actions" buttons wrap on mobile instead of running off the edge.
## Internal
- The JD Index description is now built with safe DOM calls instead of an HTML
string (clears the last `no-inner-html` lint the store review flags).
- Clarified OKF copy to point at **Stashpad's own Templates section**, not
Obsidian's core Templates plugin.

58
src/icon-suggest.ts Normal file
View file

@ -0,0 +1,58 @@
import { AbstractInputSuggest, App, getIconIds, setIcon } from "obsidian";
/** Lucide icon-name autocomplete for a settings text input type a few letters
* and pick from matching icons (each shown WITH its preview) instead of having
* to know icon ids by heart. Mirrors {@link FolderSuggest}.
*
* Stores the bare name (strips the internal "lucide-" prefix) since that's what
* `setIcon` + the rest of the plugin already use (`rocket`, not `lucide-rocket`).
*
* Wire it up:
* ```
* .addText((t) => {
* new IconSuggest(this.app, t.inputEl);
* t.setValue(...).onChange(...);
* })
* ```
* 0.121.10. */
export class IconSuggest extends AbstractInputSuggest<string> {
constructor(app: App, private inputEl: HTMLInputElement) {
super(app, inputEl);
}
private static norm(id: string): string {
return id.replace(/^lucide-/, "");
}
protected getSuggestions(query: string): string[] {
// all-tokens, any-order match so "arrow up" finds "arrow-up", "move-up", etc.
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
const seen = new Set<string>();
const ids: string[] = [];
for (const raw of getIconIds()) {
const id = IconSuggest.norm(raw);
if (seen.has(id)) continue;
seen.add(id);
ids.push(id);
}
const match = (id: string): boolean => tokens.every((t) => id.toLowerCase().includes(t));
const out = tokens.length ? ids.filter(match) : ids;
// Cap the list — the popover only renders a window anyway, and an unfiltered
// getIconIds() is ~1.5k entries.
return out.sort().slice(0, 50);
}
renderSuggestion(id: string, el: HTMLElement): void {
el.addClass("stashpad-icon-suggest-item");
const ic = el.createSpan({ cls: "stashpad-icon-suggest-icon" });
setIcon(ic, id);
el.createSpan({ cls: "stashpad-icon-suggest-label", text: id });
}
selectSuggestion(id: string): void {
this.setValue(id);
// Fire `input` so the caller's onChange listener persists + repaints.
this.inputEl.dispatchEvent(new Event("input", { bubbles: true }));
this.close();
}
}

View file

@ -6,6 +6,7 @@ function osFileManagerName(): string {
}
import { buildJdIndexPreview, buildJdIndexNotes, scanForJdNotes, JdBuildConfirmModal, buildJdPreviewNotice } from "./index-builder";
import { FolderSuggest } from "./folder-suggest";
import { IconSuggest } from "./icon-suggest";
import type StashpadPlugin from "./main";
import { RESERVED_FRONTMATTER, type ViewMode } from "./types";
import { type SplitMode } from "./view-helpers";
@ -650,29 +651,25 @@ export function getTemplatesFormats(app: App): { dateFormat: string; timeFormat:
* is the source of truth for both the bar at the top and the
* search-mode group order. Order here = display order. */
export type SettingsTabId =
| "foldersStorage" | "importExport" | "noteTitles" | "datesTime" | "listDisplay"
| "movingNotes" | "deleting" | "composerCopy" | "windowsTabs" | "notifications"
| "encryption" | "authorship" | "templates" | "jdindex" | "okf"
| "maintenance" | "diagnostics" | "misc" | "hotkeys";
| "foldersStorage" | "importExport" | "datesTime" | "behaviors"
| "notifications" | "encryption" | "authorship" | "templates"
| "organizationSystems" | "maintenance" | "diagnostics" | "hotkeys";
export const SETTINGS_TABS: Array<{ id: SettingsTabId; label: string }> = ([
{ id: "foldersStorage", label: "📁 Folders & Storage" },
{ id: "importExport", label: "🔄 Import & Export" },
{ id: "noteTitles", label: "🏷️ Note Titles" },
{ id: "datesTime", label: "🕒 Dates & Time" },
{ id: "listDisplay", label: "📋 List & Display" },
{ id: "movingNotes", label: "↕️ Moving Notes" },
{ id: "deleting", label: "🗑️ Deleting" },
{ id: "composerCopy", label: "✍️ Composer & Copying" },
{ id: "windowsTabs", label: "🪟 Windows & Tabs" },
// 0.121.9: the six toggle-only sections (List & Display, Moving Notes,
// Deleting, Composer & Copying, Windows & Tabs, Misc) are folded into one
// "Behavior" tab as sub-headings (see itemsForTab).
{ id: "behaviors", label: "🎛️ Behavior" },
{ id: "notifications", label: "🔔 Notifications" },
{ id: "encryption", label: "🔒 Encryption" },
{ id: "authorship", label: "✒️ Authorship" },
{ id: "templates", label: "📄 Templates" },
{ id: "jdindex", label: "🔢 JD Index" },
{ id: "okf", label: "📚 Open Knowledge Format (OKF)" },
// 0.121.9: JD Index + OKF folded into one "Organization Systems" tab.
{ id: "organizationSystems", label: "🗂️ Organization Systems" },
{ id: "maintenance", label: "🛠️ Maintenance" },
{ id: "diagnostics", label: "🩺 Diagnostics" },
{ id: "misc", label: "⚙️ Misc" },
{ id: "hotkeys", label: "⌨️ Hotkeys" },
// 0.112.9: sections shown alphabetically by label. Display order only — the
// `id`-keyed itemsForTab dispatch is unaffected, and new tabs auto-sort in.
@ -792,23 +789,32 @@ export class StashpadSettingTab extends PluginSettingTab {
// General split into focused categories (0.110.0) — each pulls its bucket.
case "foldersStorage": return this.buildGeneralCategories().foldersStorage;
case "importExport": return this.buildGeneralCategories().importExport;
case "noteTitles": return this.buildGeneralCategories().noteTitles;
case "datesTime": return this.buildGeneralCategories().datesTime;
case "listDisplay": return this.buildGeneralCategories().listDisplay;
case "movingNotes": return this.buildGeneralCategories().movingNotes;
case "deleting": return this.buildGeneralCategories().deleting;
case "composerCopy": return this.buildGeneralCategories().composerCopy;
case "windowsTabs": return this.buildGeneralCategories().windowsTabs;
case "maintenance": return this.buildGeneralCategories().maintenance;
case "misc": return this.buildGeneralCategories().misc;
// 0.121.9: "Behavior" merges the six toggle-only sections, each kept as a
// labelled sub-heading so the groupings still read clearly.
case "behaviors": {
const c = this.buildGeneralCategories();
return [
this.headingDef("📋 List & Display"), ...c.listDisplay,
this.headingDef("↕️ Moving Notes"), ...c.movingNotes,
this.headingDef("✍️ Composer & Copying"), ...c.composerCopy,
this.headingDef("🗑️ Deleting"), ...c.deleting,
this.headingDef("🪟 Windows & Tabs"), ...c.windowsTabs,
this.headingDef("⚙️ Misc"), ...c.misc,
];
}
// 0.121.9: "Organization Systems" merges JD Index + OKF as sub-sections.
case "organizationSystems": return [
this.headingDef("🔢 JD Index (Johnny Decimal)"), ...this.jdIndexItems(),
this.headingDef("📚 Open Knowledge Format (OKF)"), ...this.okfItems(),
];
case "encryption": return this.encryptionItems();
// 0.99.15: authorship/templates/jdindex decomposed too — static fields as
// per-setting items, the per-folder editors as sectionDefs (rendered fresh
// at display) — so individual settings are searchable, not just page names.
case "authorship": return this.authorshipItems();
case "templates": return this.templatesItems();
case "jdindex": return this.jdIndexItems();
case "okf": return this.okfItems();
default: return null;
}
}
@ -820,8 +826,8 @@ export class StashpadSettingTab extends PluginSettingTab {
switch (tab) {
case "authorship": this.renderAuthorshipSection(parent); break;
case "templates": this.renderTemplatesTab(parent); break;
case "jdindex": this.renderJdIndexSection(parent); break;
// hotkeys migrated to declarative items (itemsForTab) — never routed here.
// jdindex/okf now live under the "Organization Systems" tab (declarative
// items via itemsForTab); hotkeys are declarative too — neither routed here.
}
}
@ -1192,7 +1198,7 @@ export class StashpadSettingTab extends PluginSettingTab {
* bucket to its own settings page. Anything that doesn't fit a precise
* category goes to `misc`. */
private buildGeneralCategories(): Record<
"foldersStorage" | "importExport" | "datesTime" | "noteTitles" | "listDisplay"
"foldersStorage" | "importExport" | "datesTime" | "listDisplay"
| "movingNotes" | "deleting" | "composerCopy" | "windowsTabs" | "maintenance" | "misc",
SettingDefinitionItem[]
> {
@ -1206,7 +1212,6 @@ export class StashpadSettingTab extends PluginSettingTab {
foldersStorage: [] as SettingDefinitionItem[],
importExport: [] as SettingDefinitionItem[],
datesTime: [] as SettingDefinitionItem[],
noteTitles: [] as SettingDefinitionItem[],
listDisplay: [] as SettingDefinitionItem[],
movingNotes: [] as SettingDefinitionItem[],
deleting: [] as SettingDefinitionItem[],
@ -1250,7 +1255,7 @@ export class StashpadSettingTab extends PluginSettingTab {
// 0.118.6: per-folder tab icon — moved here from the Encryption tab's
// per-folder panel so it's searchable. Pick a folder, then enter a Lucide
// icon id; a live preview shows whether it's valid.
cats.foldersStorage.push(this.renderDef("Folder tab icon", "Give a folder its own Lucide icon (e.g. rocket, star, book-open) shown on its tab, the folder switcher, and its folder-panel row. Pick a folder, then enter an icon id (browse at lucide.dev). Blank = the default icon. Set per folder.", (s) => {
cats.foldersStorage.push(this.renderDef("Folder tab icon", "Give a folder its own Lucide icon (e.g. rocket, star, book-open) shown on its tab, the folder switcher, and its folder-panel row. Pick a folder, then start typing in the icon box — matching icons appear with previews; pick one. Blank = the default icon. Set per folder.", (s) => {
const folders = this.plugin.discoverStashpadFolders();
if (folders.length === 0) { s.setDesc("No Stashpad folders found yet."); return; }
if (!this.iconPickFolder || !folders.includes(this.iconPickFolder)) this.iconPickFolder = folders[0];
@ -1264,9 +1269,11 @@ export class StashpadSettingTab extends PluginSettingTab {
});
s.addText((t) => {
textComp = t;
t.setPlaceholder("list-tree");
t.setPlaceholder("type to search… (e.g. rocket)");
// 0.121.10: type-ahead icon search with previews — no need to know ids.
new IconSuggest(this.app, t.inputEl);
t.setValue(this.plugin.getFolderIcon(this.iconPickFolder!) ?? "");
t.onChange(async (v) => { paint(v); await this.plugin.setFolderIcon(this.iconPickFolder!, v); });
t.onChange(async (v) => { paint(v); await this.plugin.setFolderIcon(this.iconPickFolder!, v.trim()); });
});
paint(this.plugin.getFolderIcon(this.iconPickFolder) ?? "");
}, ["icon", "folder", "tab", "lucide", "emoji", "switcher"]));
@ -1306,24 +1313,16 @@ export class StashpadSettingTab extends PluginSettingTab {
this.plugin.settings.writeRecoveryLinks = v; await set();
})), ["recovery", "parentlink", "children", "frontmatter"]));
cats.datesTime.push(this.renderDef("Use Templates plugin date/time formats", "When on, timestamps use the formats configured in the core Templates plugin. Off: YYYY.MM.DD + HH:mm A.", (s) => {
s.addToggle((t) => t.setValue(this.plugin.settings.useTemplatesFormat).onChange(async (v) => {
this.plugin.settings.useTemplatesFormat = v; await set();
}));
const fmt = getTemplatesFormats(this.app);
s.descEl.createDiv({ cls: "stashpad-settings-note" }).setText(fmt
? `Templates plugin: date = "${fmt.dateFormat}", time = "${fmt.timeFormat}"`
: "Templates plugin not enabled.");
}, ["templates", "date", "time", "format"]));
// Date display block — dropdown + timezone share a live sample element.
// Date display block — dropdown leads (it now drives EVERY timestamp surface:
// notes list, Tasks, detail panel), then the Templates-format toggle, the
// timezone, and a live sample. 0.121.7: reordered + copy updated.
{
let sampleEl: HTMLElement | null = null;
const refreshSample = () => {
if (!sampleEl) return;
sampleEl.setText(`Sample: ${formatDateTime(Date.now(), this.plugin.settings)}`);
};
cats.datesTime.push(this.renderDef("Date display format", "How due dates and created/modified times are shown in the Tasks and detail panels.", (s) => {
cats.datesTime.push(this.renderDef("Date display format", "How created / edited timestamps and due dates are shown — in the notes list, the Tasks panel, and the detail panel. Overridden by the Templates-plugin formats when the option below is on.", (s) => {
s.addDropdown((d) => {
d.addOption("locale", "Locale, short (Mar 5, 9:00 AM)");
d.addOption("long", "Locale, long (Thursday, March 5…)");
@ -1334,6 +1333,15 @@ export class StashpadSettingTab extends PluginSettingTab {
d.onChange(async (v) => { this.plugin.settings.dateDisplayFormat = v as any; await set(); refreshSample(); });
});
}, ["date", "format", "display"]));
cats.datesTime.push(this.renderDef("Use Templates plugin date/time formats", "Use the date/time formats configured in the core Templates plugin instead of the Date display format above. Off = the Date display format above is used everywhere.", (s) => {
s.addToggle((t) => t.setValue(this.plugin.settings.useTemplatesFormat).onChange(async (v) => {
this.plugin.settings.useTemplatesFormat = v; await set(); refreshSample();
}));
const fmt = getTemplatesFormats(this.app);
s.descEl.createDiv({ cls: "stashpad-settings-note" }).setText(fmt
? `Templates plugin: date = "${fmt.dateFormat}", time = "${fmt.timeFormat}"`
: "Templates plugin not enabled.");
}, ["templates", "date", "time", "format"]));
cats.datesTime.push(this.renderDef("Display timezone", "IANA timezone name (e.g. America/New_York, Europe/London, Asia/Kolkata). Leave blank to use your system timezone.", (s) => {
s.addText((t) => {
t.setPlaceholder("(system timezone)");
@ -1344,8 +1352,10 @@ export class StashpadSettingTab extends PluginSettingTab {
cats.datesTime.push({
name: "Date sample", searchable: false,
render: (s: Setting) => {
const host = s.settingEl; host.empty(); host.removeClass("setting-item");
sampleEl = host.createDiv({ cls: "setting-item-description stashpad-settings-note" });
// 0.121.5: mark the host as a settings-section so the sample picks up
// the standard 14px inset (otherwise it sits flush against the edge).
const host = s.settingEl; host.empty(); host.removeClass("setting-item"); host.addClass("stashpad-settings-section");
sampleEl = host.createDiv({ cls: "setting-item-description stashpad-settings-note stashpad-date-sample" });
refreshSample();
},
});
@ -1372,26 +1382,6 @@ export class StashpadSettingTab extends PluginSettingTab {
cats.deleting.push(toggle("Offer to delete attachments with note", "When a note references attachments, the delete modal includes an \"Also delete attachments\" checkbox so orphaned files don't pile up in your vault. Attachments are detected from both ![[…]] embeds in the body and the frontmatter attachments: list. Off = attachments are always preserved on delete (no checkbox shown), and a single childless note with attachments deletes silently.",
() => this.plugin.settings.confirmAttachmentDelete, (v) => { this.plugin.settings.confirmAttachmentDelete = v; }, ["delete", "attachment", "orphan"]));
cats.noteTitles.push(this.renderDef("Slug stop-words", "Words removed from auto-generated note titles (filenames). One per line.", (s) => {
let textarea: HTMLTextAreaElement | null = null;
const initial = (this.plugin.settings.slugStopWords?.length ? this.plugin.settings.slugStopWords : DEFAULT_STOPWORDS).join("\n");
s.addTextArea((t) => {
t.setValue(initial);
textarea = (t as any).inputEl as HTMLTextAreaElement;
textarea.rows = 6;
textarea.setCssStyles({ fontFamily: "var(--font-monospace)" });
t.onChange(async (v) => {
this.plugin.settings.slugStopWords = (v || "").split(/\r?\n/).map((x) => x.trim().toLowerCase()).filter(Boolean);
await set();
});
}).addExtraButton((b) =>
b.setIcon("rotate-ccw").setTooltip("Reset to defaults").onClick(async () => {
this.plugin.settings.slugStopWords = [...DEFAULT_STOPWORDS];
if (textarea) textarea.value = DEFAULT_STOPWORDS.join("\n");
await set();
}));
}, ["slug", "stopwords", "filename", "title"]));
cats.foldersStorage.push(this.sectionDef("Cross-Stashpad Search Scope", "Toggle each Stashpad's pill to choose whether its notes contribute to cross-folder search. Excluded folders are still valid move destinations. Also: create a new Stashpad.", (host) => {
const folders = this.plugin.discoverStashpadFolders();
new Setting(host)
@ -1433,6 +1423,29 @@ export class StashpadSettingTab extends PluginSettingTab {
this.renderFolderPlacementList(host);
}, ["folder", "panel", "pin", "pinned", "downrank", "hide", "hidden", "restore", "placement"]));
// 0.121.6: Note Titles folded into Folders & Storage as a trailing
// sub-section (was its own tab). Keep the labelled "🏷️ Note Titles" header.
cats.foldersStorage.push(this.headingDef("🏷️ Note Titles"));
cats.foldersStorage.push(this.renderDef("Slug stop-words", "Words removed from auto-generated note titles (filenames). One per line.", (s) => {
let textarea: HTMLTextAreaElement | null = null;
const initial = (this.plugin.settings.slugStopWords?.length ? this.plugin.settings.slugStopWords : DEFAULT_STOPWORDS).join("\n");
s.addTextArea((t) => {
t.setValue(initial);
textarea = (t as any).inputEl as HTMLTextAreaElement;
textarea.rows = 6;
textarea.setCssStyles({ fontFamily: "var(--font-monospace)" });
t.onChange(async (v) => {
this.plugin.settings.slugStopWords = (v || "").split(/\r?\n/).map((x) => x.trim().toLowerCase()).filter(Boolean);
await set();
});
}).addExtraButton((b) =>
b.setIcon("rotate-ccw").setTooltip("Reset to defaults").onClick(async () => {
this.plugin.settings.slugStopWords = [...DEFAULT_STOPWORDS];
if (textarea) textarea.value = DEFAULT_STOPWORDS.join("\n");
await set();
}));
}, ["slug", "stopwords", "filename", "title", "note titles"]));
cats.composerCopy.push(toggle("Autofocus composer after sending", "After Enter-submitting a note, return focus to the composer so you can keep typing. Off keeps focus in the list — useful if you want arrow keys to work without an extra click.",
() => this.plugin.settings.autofocusComposerAfterSend, (v) => { this.plugin.settings.autofocusComposerAfterSend = v; }, ["composer", "focus", "send"]));
cats.windowsTabs.push(toggle("Open in new window — duplicate tab", "ON: the new-window button (in the time-filter row) duplicates the current Stashpad tab — original stays open in the main window. OFF: the leaf is MOVED to the new window, closing the original tab.",
@ -1845,12 +1858,29 @@ export class StashpadSettingTab extends PluginSettingTab {
private renderJdIndexSection(containerEl: HTMLElement): void {
const header = new Setting(containerEl).setName("JD Index Builder").setHeading();
header.settingEl.id = "stashpad-jd-index-section";
const blurb = containerEl.createEl("p", { cls: "setting-item-description" });
blurb.innerHTML =
'Builds a Johnny-Decimal-style index inside a designated Stashpad folder. Two commands:' +
'<br/><strong>Preview</strong> overwrites the designated folder&rsquo;s HOME note body with the would-be hierarchy + everything that didn&rsquo;t match. Frontmatter is preserved; everything below it is replaced.' +
'<br/><strong>Build</strong> creates an actual hierarchy of Stashpad notes (one per prefix), with child→parent relationships matching the dotted segments.' +
'<br/>Matches strict prefixes only: all-digits (<code>10 Life</code>) or alphanumeric-with-dots (<code>1.2 Family</code>, <code>animal.duck.yellow Eggs</code>). Mixed schemes sort numbers first, then alphabetically.';
// 0.121.2: render the description into a .stashpad-settings-section host so
// it picks up the standard 14px inset (a bare <p> on containerEl sits flush
// against the modal edge — it's not a Setting row and not a section child).
const blurb = containerEl.createEl("div", { cls: "stashpad-settings-section" })
.createEl("p", { cls: "setting-item-description" });
// 0.121.4: built with safe DOM helpers instead of innerHTML (the Obsidian
// store linter flags raw HTML strings). Same rendered output: <br> breaks,
// <strong> command names, <code> prefix examples.
blurb.appendText("Builds a Johnny-Decimal-style index inside a designated Stashpad folder. Two commands:");
blurb.createEl("br");
blurb.createEl("strong", { text: "Preview" });
blurb.appendText(" overwrites the designated folders HOME note body with the would-be hierarchy + everything that didnt match. Frontmatter is preserved; everything below it is replaced.");
blurb.createEl("br");
blurb.createEl("strong", { text: "Build" });
blurb.appendText(" creates an actual hierarchy of Stashpad notes (one per prefix), with child→parent relationships matching the dotted segments.");
blurb.createEl("br");
blurb.appendText("Matches strict prefixes only: all-digits (");
blurb.createEl("code", { text: "10 Life" });
blurb.appendText(") or alphanumeric-with-dots (");
blurb.createEl("code", { text: "1.2 Family" });
blurb.appendText(", ");
blurb.createEl("code", { text: "animal.duck.yellow Eggs" });
blurb.appendText("). Mixed schemes sort numbers first, then alphabetically.");
const stashpadFolders = this.plugin.discoverStashpadFolders();
@ -1923,7 +1953,8 @@ export class StashpadSettingTab extends PluginSettingTab {
// Preview line: shows current counts before building.
const scan = scanForJdNotes(this.app, this.plugin, this.plugin.settings);
const previewEl = containerEl.createEl("p", { cls: "setting-item-description" });
const previewEl = containerEl.createEl("div", { cls: "stashpad-settings-section" })
.createEl("p", { cls: "setting-item-description" });
const skippedSuffix = scan.skippedStashpadNotes.length > 0
? ` (${scan.skippedStashpadNotes.length} Stashpad-folder note${scan.skippedStashpadNotes.length === 1 ? "" : "s"} excluded by default)`
: "";
@ -1934,6 +1965,7 @@ export class StashpadSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName("Actions")
.setClass("stashpad-jd-actions")
.setDesc("Preview aggressively overwrites the designated folder's HOME note body (frontmatter preserved). Build creates Stashpad notes (existing notes with the same jdPrefix are updated, not duplicated).")
.addButton((b) => {
b.setButtonText("Preview");
@ -2158,6 +2190,8 @@ export class StashpadSettingTab extends PluginSettingTab {
/** The "folders you've worked in" list, extracted so the authorship sectionDef
* can render it fresh at display time. */
private renderAuthoredFolders(parent: HTMLElement): void {
// 0.121.5: this sectionDef renders no heading of its own, so label the list.
new Setting(parent).setName("Folders you've worked in").setHeading();
const folders = this.plugin.collectAuthoredFolders();
if (folders.length === 0) { parent.createEl("p", { cls: "setting-item-description", text: "No authored or contributed folders yet." }); return; }
const list = parent.createDiv({ cls: "stashpad-authored-folders-list" });
@ -2227,7 +2261,7 @@ export class StashpadSettingTab extends PluginSettingTab {
new Setting(parent)
.setName("Enable OKF")
.setDesc(this.codeDesc("Master switch. When on, you choose which folders use OKF by assigning the OKF template to them in Settings → Templates (all / some / none — your call). Those folders then get OKF frontmatter and a maintained `index.md`. Turning this off leaves existing OKF files in place; it just stops maintaining them."))
.setDesc(this.codeDesc("Master switch. When on, you choose which folders use OKF by assigning the OKF template to them in Stashpad's own Templates section (the 📄 Templates settings here — NOT Obsidian's core Templates plugin) — all / some / none, your call. Those folders then get OKF frontmatter and a maintained `index.md`. Turning this off leaves existing OKF files in place; it just stops maintaining them."))
.addToggle((t) => t.setValue(this.plugin.settings.okfEnabled).onChange(async (v) => {
this.plugin.settings.okfEnabled = v;
await this.plugin.saveSettings();
@ -2244,7 +2278,7 @@ export class StashpadSettingTab extends PluginSettingTab {
const steps = parent.createEl("div", { cls: "setting-item-description stashpad-okf-howto" });
steps.createEl("p", { text: "How to use OKF in a folder:" });
const ol = steps.createEl("ol");
this.appendCode(ol.createEl("li"), `Open Templates and set a folder's template to \`${okfPath}\` (archive folders are skipped).`);
this.appendCode(ol.createEl("li"), `Open Stashpad's 📄 Templates section (here in Stashpad's settings — not Obsidian's core Templates plugin) and set a folder's template to \`${okfPath}\` (archive folders are skipped).`);
this.appendCode(ol.createEl("li"), "Hit Rebuild below to write OKF frontmatter (`okfParent`/`okfChildren` + `okfType`/`okfTitle`/`okfTimestamp`) and generate that folder's `index.md`.");
this.appendCode(ol.createEl("li"), "Right-click a note (or a selection) → “Export as OKF…” to save a `.zip` / `.tar.gz` bundle (or `.stash`).");
steps.createEl("p", { cls: "stashpad-okf-soon", text: "OKF frontmatter + index.md refresh automatically a few seconds after you add, move, or delete notes — NOT instantly. Use Rebuild for an immediate pass." });
@ -2817,15 +2851,28 @@ export class StashpadSettingTab extends PluginSettingTab {
/** One settings row: label + 2 chord recorders + active-slot toggle. */
private renderBindingRow(row: Setting, meta: CommandMeta): void {
row.setName(meta.label).setDesc(meta.desc);
row.settingEl.addClass("stashpad-binding-row");
const get = () => this.plugin.settings.bindings[meta.id];
let primaryInput: HTMLInputElement;
let secondaryInput: HTMLInputElement;
// Late-bound: assigned once the pill toggle is built below.
let refreshToggle = (): void => {};
// 0.121.11 (#8/#9): a tri-state segmented control (Left | Both | Right) on
// the LEFT, then the two key slots STACKED on the right — replacing the old
// L/R pill + separate "Use both" checkbox. `setState` is assigned once the
// slots exist (below); the buttons just call it.
let setState = async (_s: "left" | "both" | "right"): Promise<void> => {};
const seg = row.controlEl.createDiv({ cls: "stashpad-binding-seg", attr: { role: "group", "aria-label": "Active binding" } });
const segBtns = {
left: seg.createEl("button", { cls: "stashpad-binding-seg-btn", text: "Left" }),
both: seg.createEl("button", { cls: "stashpad-binding-seg-btn", text: "Both" }),
right: seg.createEl("button", { cls: "stashpad-binding-seg-btn", text: "Right" }),
};
(["left", "both", "right"] as const).forEach((k) => { segBtns[k].onclick = () => void setState(k); });
const slotsCol = row.controlEl.createDiv({ cls: "stashpad-binding-slots" });
const renderSlot = (which: "primary" | "secondary"): HTMLInputElement => {
const wrap = row.controlEl.createDiv({ cls: "stashpad-binding-slot" });
const wrap = slotsCol.createDiv({ cls: "stashpad-binding-slot" });
const input = wrap.createEl("input", { type: "text" });
input.readOnly = true;
input.placeholder = "Click & press a key";
@ -2850,7 +2897,10 @@ export class StashpadSettingTab extends PluginSettingTab {
syncRevert();
});
};
const clearBtn = wrap.createEl("button", { cls: "stashpad-binding-clear", text: "×" });
// 0.121.12 (#4): clear (×) + revert (↺) share a wrapper so they can stack
// vertically on mobile instead of widening the row.
const slotBtns = wrap.createDiv({ cls: "stashpad-binding-slot-btns" });
const clearBtn = slotBtns.createEl("button", { cls: "stashpad-binding-clear", text: "×" });
clearBtn.title = "Clear this slot";
clearBtn.onclick = async () => {
this.plugin.settings.bindings[meta.id][which] = "";
@ -2866,7 +2916,7 @@ export class StashpadSettingTab extends PluginSettingTab {
// one click. Hidden when the slot already matches its default (nothing to
// revert). A slot with no default ("") only shows it after the user binds
// something, and reverting then clears the slot.
const revertBtn = wrap.createEl("button", { cls: "stashpad-binding-revert" });
const revertBtn = slotBtns.createEl("button", { cls: "stashpad-binding-revert" });
setIcon(revertBtn, "rotate-ccw");
const syncRevert = (): void => {
const cur = get()[which];
@ -2892,23 +2942,14 @@ export class StashpadSettingTab extends PluginSettingTab {
secondaryInput = renderSlot("secondary");
void primaryInput; void secondaryInput;
// Active-slot pill toggle: a rounded track with a sliding knob whose
// label is "L" when on the left (primary active) and "R" when on the
// right (secondary active). Greyed out unless BOTH slots are bound.
const pill = row.controlEl.createDiv({ cls: "stashpad-binding-pill" });
pill.setAttribute("role", "switch");
pill.setAttribute("tabindex", "0");
const knob = pill.createDiv({ cls: "stashpad-binding-pill-knob" });
// 0.59.1: "Use both" checkbox — when checked, both bindings fire and
// the L/R pill becomes a no-op (visually greyed). Only meaningful
// when both slots are filled.
const bothWrap = row.controlEl.createDiv({ cls: "stashpad-binding-useboth" });
const bothCb = bothWrap.createEl("input", { type: "checkbox" });
bothCb.title = "Use both bindings simultaneously (overrides the L/R toggle)";
bothWrap.createSpan({ text: "Use both" });
bothCb.onchange = async () => {
this.plugin.settings.bindings[meta.id].useBoth = bothCb.checked;
// 0.121.11: tri-state behaviour. Left = primary only, Right = secondary
// only, Both = both fire. Only meaningful once BOTH slots are bound (until
// then the lone bound slot just fires and the control is disabled).
setState = async (state: "left" | "both" | "right"): Promise<void> => {
const b = this.plugin.settings.bindings[meta.id];
if (!(b.primary && b.secondary)) return;
if (state === "both") { b.useBoth = true; }
else { b.useBoth = false; b.preferRight = state === "right"; }
await this.plugin.saveSettings();
refreshToggle();
};
@ -2916,36 +2957,15 @@ export class StashpadSettingTab extends PluginSettingTab {
refreshToggle = (): void => {
const b = get();
const both = !!(b.primary && b.secondary);
bothCb.checked = !!b.useBoth;
bothCb.disabled = !both;
bothWrap.toggleClass("is-disabled", !both);
const useBoth = !!b.useBoth && both;
// L/R pill: disabled when fewer than two slots OR when useBoth wins.
pill.toggleClass("is-disabled", !both || useBoth);
pill.toggleClass("is-right", b.preferRight);
pill.setAttribute("aria-checked", String(b.preferRight));
pill.setAttribute("aria-disabled", String(!both || useBoth));
knob.setText(b.preferRight ? "R" : "L");
pill.title = !both
? "Set both slots to enable the toggle"
: useBoth
? "Overridden by \"Use both\""
: (b.preferRight ? "Right slot active — click for left" : "Left slot active — click for right");
};
const flip = async () => {
const b = get();
if (!b.primary || !b.secondary) return;
this.plugin.settings.bindings[meta.id].preferRight = !b.preferRight;
refreshToggle();
await this.plugin.saveSettings();
};
pill.onclick = () => void flip();
pill.onkeydown = (e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
void flip();
}
const active: "left" | "both" | "right" = b.useBoth ? "both" : (b.preferRight ? "right" : "left");
(["left", "both", "right"] as const).forEach((k) => {
segBtns[k].toggleClass("is-active", both && k === active);
segBtns[k].disabled = !both;
});
seg.toggleClass("is-disabled", !both);
seg.title = both
? "Which binding is active — Left, Both, or Right"
: "Bind both slots to choose Left / Both / Right";
};
refreshToggle();

View file

@ -9,7 +9,7 @@ import {
} from "./types";
import { TreeIndex } from "./tree-index";
import { perf } from "./perf";
import { formatDateTime } from "./format";
import { formatDateTime, formatDateOnly, formatTimeOnly } from "./format";
import { OrderStore } from "./order-store";
import { SortStore, SORT_MODE_LABELS, SORT_MODES_ORDER } from "./sort-store";
import { FrontmatterSyncQueue, rebootstrapFolderFrontmatter } from "./frontmatter-sync";
@ -11021,7 +11021,12 @@ export class StashpadView extends ItemView {
const fmt = getTemplatesFormats(this.app);
if (fmt) return `${d.format(fmt.dateFormat)}\n${d.format(fmt.timeFormat)}`;
}
return `${d.format("YYYY.MM.DD")}\n${d.format("HH:mm A")}`;
// 0.121.7: when NOT using the Templates plugin format, honour the user's
// "Date display format" dropdown (+ timezone) for the two-line list
// timestamp — previously this ignored the dropdown and was hardcoded to
// YYYY.MM.DD / HH:mm A, so the dropdown only affected the detail/tasks panels.
const ms = d.valueOf();
return `${formatDateOnly(ms, settings)}\n${formatTimeOnly(ms, settings)}`;
}
/** public: read by extracted command modules (commands/*.ts). */
formatTimeInline(iso: string): string {

View file

@ -893,6 +893,27 @@
flex: 0 0 auto;
}
/* 0.118.0: live preview swatch next to the per-folder icon input in settings. */
/* 0.121.10: icon-search suggestion rows (IconSuggest) — preview + id label. */
.stashpad-icon-suggest-item {
display: flex;
align-items: center;
gap: 10px;
}
.stashpad-icon-suggest-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 20px;
height: 20px;
color: var(--text-normal);
}
.stashpad-icon-suggest-icon .svg-icon { width: 18px; height: 18px; }
.stashpad-icon-suggest-label {
font-family: var(--font-monospace);
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.stashpad-folder-icon-preview {
display: inline-flex;
align-items: center;
@ -1411,7 +1432,10 @@ body.is-phone .modal.stashpad-compact-modal.stashpad-breadcrumb-modal {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0 12px;
/* 0.121.5: inset to align with the section heading + Setting rows.
0.121.7: bumped 1420px per feedback that 14 read under-indented vs the
rest. (Approximate verify against the live settings.) */
padding: 4px 20px 12px;
}
.stashpad-authored-folder-row {
display: flex;
@ -1997,11 +2021,17 @@ body.is-phone .modal.stashpad-compact-modal.stashpad-breadcrumb-modal {
.stashpad-folder-list {
display: flex;
flex-direction: column;
padding: 4px 0 12px;
/* 0.121.2: 14px left/right inset so these custom rows align with the Setting
rows + section text (PROVISION insets text element types, not custom divs,
so the list was sitting flush against the modal edge). */
padding: 4px 14px 12px;
}
/* 0.95.2: placement overview group heading in settings */
.stashpad-folder-placement-group {
margin-top: 10px;
/* 0.121.2: match the 14px inset of its rows + the section text above. */
padding-left: 14px;
padding-right: 14px;
font-size: var(--font-ui-smaller);
font-weight: var(--font-semibold);
color: var(--text-muted);
@ -2269,7 +2299,10 @@ body.is-phone .modal.stashpad-compact-modal.stashpad-breadcrumb-modal {
display: flex;
flex-direction: column;
gap: 6px;
/* 0.121.5: 14px left/right inset so the author cards align with the rest. */
margin: 8px 0 4px;
padding-left: 14px;
padding-right: 14px;
}
.stashpad-known-author-row {
padding: 6px 10px;
@ -2497,18 +2530,12 @@ body.is-phone .modal.stashpad-compact-modal.stashpad-breadcrumb-modal {
text-align: center;
font-family: var(--font-monospace);
cursor: pointer;
}
.stashpad-binding-useboth {
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: 8px;
font-size: 0.85em;
color: var(--text-muted);
}
.stashpad-binding-useboth.is-disabled {
opacity: 0.45;
pointer-events: none;
/* 0.121.12 (#4): compact rounded key-chip look (desktop + mobile). */
height: 26px;
padding: 2px 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 5px;
background: var(--background-secondary);
}
.stashpad-binding-input.is-recording {
background: var(--background-modifier-active-hover);
@ -2559,49 +2586,77 @@ body.is-phone .modal.stashpad-compact-modal.stashpad-breadcrumb-modal {
/* Pill toggle: a rounded track with a knob that slides LR. The knob
shows "L" when on the left (primary active) or "R" when on the right
(secondary active). Disabled state dims the whole pill. */
.stashpad-binding-pill {
position: relative;
display: inline-block;
width: 48px;
height: 22px;
margin-left: 8px;
border-radius: 11px;
background: var(--background-modifier-border);
cursor: pointer;
transition: background 0.15s ease;
vertical-align: middle;
outline: none;
}
.stashpad-binding-pill:focus-visible {
box-shadow: 0 0 0 2px var(--interactive-accent);
}
.stashpad-binding-pill.is-right {
background: var(--interactive-accent);
}
.stashpad-binding-pill.is-disabled {
opacity: 0.4;
cursor: not-allowed;
}
.stashpad-binding-pill-knob {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--background-primary);
color: var(--text-normal);
font-size: 11px;
font-weight: 700;
display: flex;
/* 0.121.11 (#8): hotkey row tri-state control on the LEFT, the two key slots
STACKED on the right so the binding controls sit at the right end. */
.stashpad-binding-row .setting-item-control {
align-items: center;
justify-content: center;
transition: left 0.15s ease, transform 0.15s ease;
user-select: none;
pointer-events: none;
gap: 12px;
flex-wrap: nowrap;
}
.stashpad-binding-pill.is-right .stashpad-binding-pill-knob {
left: calc(100% - 20px);
.stashpad-binding-slots {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
}
.stashpad-binding-slots .stashpad-binding-slot { margin-right: 0; }
/* 0.121.11 (#9): Left | Both | Right segmented control replaces the old L/R
pill + separate "Use both" checkbox. */
.stashpad-binding-seg {
display: inline-flex;
flex: 0 0 auto;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
overflow: hidden;
}
.stashpad-binding-seg-btn {
padding: 3px 10px;
font-size: var(--font-ui-smaller);
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
color: var(--text-muted);
cursor: pointer;
}
.stashpad-binding-seg-btn:not(:last-child) {
border-right: 1px solid var(--background-modifier-border);
}
.stashpad-binding-seg-btn:hover:not(:disabled) {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.stashpad-binding-seg-btn.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.stashpad-binding-seg.is-disabled { opacity: 0.45; }
.stashpad-binding-seg-btn:disabled { cursor: not-allowed; }
/* 0.121.12 (#4): the clear (×) + revert () buttons sit side-by-side on desktop,
stacked vertically on mobile to keep the (stacked) slots narrow. */
.stashpad-binding-slot-btns { display: inline-flex; gap: 4px; align-items: center; }
body.is-mobile .stashpad-binding-slot-btns { flex-direction: column; gap: 3px; }
/* 0.121.12 (#1/#3): give each folded-tab sub-heading top breathing room the
first one (e.g. "List & Display", "JD Index") was cramped right under the
page title with no separation. */
.stashpad-settings-section .setting-item-heading {
margin-top: 16px;
padding-top: 4px;
}
/* 0.121.13: the FIRST sub-heading of a folded tab (top of Behavior / Org
Systems) gets extra top room so it doesn't crowd the page title. */
.stashpad-settings-section:first-child .setting-item-heading {
margin-top: 28px;
padding-top: 6px;
}
/* 0.121.12 (#2): the JD "Actions" buttons spilled off the right on mobile
let them wrap instead. */
body.is-mobile .stashpad-jd-actions .setting-item-control {
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
/* Color picker modal */
.stashpad-color-modal { width: min(360px, 90vw); }
@ -2743,7 +2798,16 @@ body.is-phone .modal.stashpad-compact-modal.stashpad-breadcrumb-modal {
/* Per-folder encryption: filename sub-toggles sit indented under their content
toggle, dimmed when disabled. */
.stashpad-subsetting { padding-left: 22px; opacity: 0.85; }
/* 0.121.2: sub-settings (e.g. the encryption filename sub-toggle under its
content toggle) get a left guide-line so the nesting reads structurally, not
just by indent + dim. Additive + stashpad-scoped (never touches global
.setting-item). */
.stashpad-subsetting {
padding-left: 18px;
margin-left: 6px;
border-left: 2px solid var(--background-modifier-border);
opacity: 0.85;
}
.stashpad-subsetting .setting-item-name { font-size: var(--font-ui-smaller); color: var(--text-muted); }
.stashpad-trash-iconbtn { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 4px 6px; cursor: pointer; --icon-size: 15px; background: transparent; border: none; box-shadow: none; color: var(--text-muted); }
.stashpad-trash-iconbtn:hover { color: var(--text-normal); }
@ -2810,6 +2874,17 @@ body.is-phone .modal.stashpad-compact-modal.stashpad-breadcrumb-modal {
padding-left: 14px;
padding-right: 14px;
}
/* 0.121.7 (#2): the live date sample read under-indented vs the rest bump it
a touch past the standard 14px. (Approximate verify against live settings.) */
.stashpad-settings-section .stashpad-date-sample {
padding-left: 20px;
}
/* 0.121.7 (#1): give each settings page some breathing room at the very bottom
so the last section isn't flush against the modal edge (e.g. the encryption
page's trailing advice text). Only the last section in a tab is affected. */
.stashpad-settings-section:last-child {
padding-bottom: 16px;
}
.stashpad-ai-disclaimer {
margin: 4px 14px 12px; padding: 10px 12px; border-radius: var(--radius-m, 6px);
border: 1px solid var(--color-red, #e24b4a);
@ -5072,7 +5147,9 @@ body.stashpad-folderpanel-resizing { cursor: row-resize; user-select: none; }
color: var(--text-normal);
background: rgba(var(--color-red-rgb, 224, 49, 49), 0.12);
border-left: 3px solid var(--color-red, #e03131);
padding: 8px 12px;
/* 0.121.5: a touch more vertical padding + more bottom breathing room so the
advice banner doesn't feel cramped (L/R stays at the section's 14px inset). */
padding: 11px 12px;
border-radius: 4px;
margin: 4px 0 12px;
margin: 4px 0 18px;
}