0.122.9: UI/UX polish — View menu icons/docs, details panel, sync-aware rendering, frameless tiny-window drag

This commit is contained in:
Human 2026-06-25 08:57:34 -07:00
parent 727b78891e
commit 434896fd66
13 changed files with 470 additions and 69 deletions

75
docs/view-menu-guide.md Normal file
View file

@ -0,0 +1,75 @@
# The View menu — modes & filters
The **View** dropdown (the `list-tree` / `list` / `layout-grid` button in the
filter bar) controls how the current folder's notes are listed, plus a set of
filters. As of 0.122.1 the inline descriptions are hidden in the menu to keep it
compact (it was clipping when the header collapses into the combined ⋯ menu at
narrow widths) — this page is the canonical, expanded reference. Each setting is
**per-folder** unless noted.
## View modes
- **Nested** *(default)* — a tree of the focused note's **immediate children**;
drill in to see the next level. Drag-to-reorder works here (the manual order is
stored per parent). This is the only mode where **Sort** applies.
- **Flat****every descendant** of the current focus in one flat list, ordered
by sort (created time when sort is non-manual). Drag-reorder is disabled
(positions are synthesized, not stored). Good for "show me everything under
here at once."
- **Everything** — like Flat, **plus** the non-Stashpad files that live in the
same folder (interleaved by created / file ctime). Lets the Stashpad view double
as a file browser for that folder.
## Encryption filter *(only when vault encryption is set up)*
- **Show all** — both locked 🔒 stubs and decrypted notes.
- **Locked only** — only the locked 🔒 stubs.
- **Decrypted only** — hide the locked 🔒 stubs.
## Filters (apply in every mode unless noted)
- **Hide childless notes** — in **Nested**, show only notes that have children
(applied at the current level). In **Flat/Everything**, hide top-level notes
without children but keep every parent's full subtree, so nothing nested is
overlooked.
- **Hide completed notes** — hide notes marked complete. A completed **parent**
stays visible while any descendant is still incomplete (so you don't lose the
path to open work).
- **Hide notes without attachments** — show only notes that have an attachment; a
parent stays visible while any descendant has one.
- **Include attachments** *(Everything mode only)* — show attachments referenced
by notes as their own rows in the file list. Off by default — they already
appear inline on the notes that embed them.
- **Imported notes only** — show only notes that came in via import (`.stash` /
dropped files).
- **By author** — filter to notes stamped with a chosen author (the dropdown
lists the distinct authors present in this folder). Most useful in
Flat/Everything, which flatten descendants.
## Notes
- **Sort** is per-parent and **Nested-only**; in Flat/Everything the list is a
synthesized flat order, so the Sort control is disabled there.
- Filters compose — e.g. "Flat + Hide completed + By author" shows one author's
outstanding work across the whole subtree.
## Verbatim in-menu descriptions (archived)
These are the exact one-line descriptions that used to render under each item in
the View menu, preserved here verbatim now that they're hidden in the UI (the
strings still live in `src/view.ts` ~38804029, just `display:none`):
| Item | Description |
| --- | --- |
| Nested | Tree of immediate children (default). |
| Flat | All descendants of the current focus, flat by sort. |
| Everything | All descendants PLUS non-Stashpad files in the folder. |
| Encryption: show all | Both locked 🔒 and decrypted notes. |
| Encryption: locked only | Show only locked 🔒 stubs. |
| Encryption: decrypted only | Hide locked 🔒 stubs. |
| Hide childless notes | Show only notes that have children. Applied at this level. |
| Hide completed notes | Hide notes marked complete. A completed parent stays visible while any descendant is still incomplete. |
| Hide notes without attachments | Show only notes that have an attachment. A parent stays visible while any descendant has one. |
| Include attachments | Only applies in Everything mode. |
| Imported notes only | Show only notes that came in via import. |
| By author | Show only notes by the chosen author. |

86
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.13",
"version": "0.122.9",
"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.13",
"version": "0.122.9",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",

95
release-notes/0.122.9.md Normal file
View file

@ -0,0 +1,95 @@
# 0.122.9
A consistency fix for mobile settings, followed by a UI/UX polish pass focused
on window width and the Obsidian window frame, the note context menu, the
details panel, and the View menu — plus two bug hunts (sticky stale renders and
sync-driven re-render storms).
## 0.121.14 — mobile settings gutter
- Settings now match Obsidian's own 20px left/right gutter on mobile, so the
Stashpad settings page lines up with the rest of the app instead of sitting at
a different inset.
## 0.122.0 — rebootstrap legibility + icon browse cap
- The "Rebootstrapping…" progress notice's sub-text inherits the notice color so
it's legible instead of rendering low-contrast grey.
- The icon picker browses up to 300 icons with an empty query (was ~50, which
stopped at the "A…" names); typed search is unaffected.
## 0.122.1 — declutter the View menu
- The View menu's inline one-line descriptions are hidden to keep the menu
compact (they were clipping when the header collapses into the combined
overflow menu at narrow widths).
- The full descriptions are preserved in a new guide, `docs/view-menu-guide.md`,
which documents every mode and filter and archives the exact in-menu strings
verbatim.
## 0.122.2 — richer note context menu
- The note context menu gains **Copy text**, and headers (the focused-note and
home rows) now get the same context menu as any other note.
- Non-task notes can be marked complete from the menu.
- (A "Cut note" item was added here and later removed in 0.122.7 — see below.)
## 0.122.3 — popout-window popovers
- The due-date time picker and the search time/calendar popovers now build into
the anchor element's own document, so they appear in the correct secondary
window instead of the main one when Stashpad is open in a popout.
## 0.122.4 — narrow settings + frameless tiny header
- First pass at keeping a settings row's controls from collapsing to ~1
character wide at narrow window widths (reworked in 0.122.7).
- In frameless windows, the tiny-mode header reserves space so its controls
don't sit under the OS window buttons — top-right on Windows/Linux, top-left
on macOS.
## 0.122.5 — details panel fixes
- Fixed multiple composer input boxes stacking on top of each other in the
details panel, caused by async render re-entrancy (guarded with a render
token).
- When viewing a note level with no child selected, the details panel now falls
back to previewing the current parent note.
## 0.122.6 — sticky stale render fix
- On a file modify, the note's render-cache entry is evicted so the row
recomputes from fresh content. This fixes a previously-open note occasionally
rendering an earlier version (truncated text, missing attachments) until a
manual reload — seen on network drives and with external/synced edits.
## 0.122.7 — View menu icons, context-menu trim, time entry, nav
- **Cut note removed** from the context menu: cut/paste has known bugs and
cutting a home/parent note is a footgun. It remains available via the
cut-notes hotkey.
- **View menu** gains leading icons (Nested, Flat, Everything, By author) and
dividers between every mode and above By author; By author is no longer
indented like a toggle.
- **Time picker** keeps exactly what was typed, including leading zeros like
`00` and `09`, and only rewrites the value when it's out of range — so a
zeroed hour or minute renders correctly.
- **Details panel** re-resolves on navigate-up and on back/forward, so exiting a
parent note updates the panel instead of leaving it pinned to the child you
were on.
- Re-attempt at the narrow-settings wrap: a control now wraps onto its own
full-width line instead of squeezing its description down to a sliver.
## 0.122.8 — defer list rendering during sync
- Obsidian Sync and other bulk writes fire a stream of file events that would
each repaint the list, making the view (especially on mobile) unusable for the
duration of a sync. Stashpad now detects a burst of file events, pauses list
re-rendering, shows a "syncing — list updates paused" notice, and repaints once
when activity settles.
## 0.122.9 — drag frameless tiny windows
- Restored the ability to move a frameless window while in tiny mode: the
tiny-mode header is now a draggable region, with its buttons, inputs, and
toggles exempted so they stay clickable. Native-frame windows are unaffected.

View file

@ -27,6 +27,11 @@ export class StashpadDetailView extends ItemView {
private unsubscribeSelection: (() => void) | null = null;
/** Coalesces a burst of selection/modify events into one render. */
private renderTimer: number | null = null;
/** 0.122.5 (#12): re-entrancy guard. render() is async (awaits cachedRead +
* MarkdownRenderer); without this, two overlapping renders (fast level/tab
* switches) both append a composer to the live root stacked input boxes.
* Each render captures a token; after an await it aborts if superseded. */
private renderToken = 0;
/** 0.74.2: ids of children-list rows currently expanded into their
* own subtrees same pattern as StashpadPanelsView.expanded. Key
* is "<folder>|<id>" so expansion state is scoped per folder
@ -129,11 +134,21 @@ export class StashpadDetailView extends ItemView {
this.displayedId = null;
}
const sel = this.plugin.getActiveStashpadSelection();
if (sel) this.displayedId = sel.id;
return sel;
if (sel) { this.displayedId = sel.id; return sel; }
// 0.122.5 (#12b): nothing selected — fall back to the focused PARENT note so
// the panel previews the current level instead of "no note selected". Not
// pinned (no displayedId): re-derived each render, so selecting a child
// immediately takes over. Home (ROOT, no file) falls through to empty.
const view = this.plugin.lastActiveStashpadLeaf?.view as any;
if (view?.getViewType?.() === STASHPAD_VIEW_TYPE && view.tree?.get && view.focusId) {
const node = view.tree.get(view.focusId);
if (node?.file) return { folder: view.noteFolder as string, id: view.focusId as StashpadId, file: node.file as TFile };
}
return null;
}
private async render(): Promise<void> {
const token = ++this.renderToken;
const root = this.contentEl;
// 0.74.4: preserve composer focus + caret across the rebuild so a
// re-render mid-typing doesn't drop the user out of the textarea.
@ -205,6 +220,10 @@ export class StashpadDetailView extends ItemView {
bodyWrap.createDiv({ cls: "stashpad-detail-error", text: `Couldn't read \`${file.path}\`: ${(e as Error).message}` });
}
// 0.122.5 (#12): a newer render started while we awaited — abort so we don't
// append a second composer (etc.) onto the root it already rebuilt.
if (token !== this.renderToken) return;
// Footer metadata — author, contributors, modified, children count.
this.renderFooterMeta(scroll, file, fm, sel);

View file

@ -37,9 +37,13 @@ export class IconSuggest extends AbstractInputSuggest<string> {
}
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);
out.sort();
// 0.122.0 (#8): a narrowing search only needs a small window, but with NO
// query the user is BROWSING — cap of 50 stranded them in the "a…" icons.
// Render a much larger window so scrolling reaches the rest of the alphabet.
// (AbstractInputSuggest has no scroll hook for true infinite-load; this is
// the pragmatic middle ground — still bounded so it stays snappy.)
return out.slice(0, tokens.length ? 50 : 300);
}
renderSuggestion(id: string, el: HTMLElement): void {

View file

@ -1737,6 +1737,11 @@ export class DueDatePickerModal extends Modal {
private tinyClosePopover: (() => void) | null = null;
private openTimeNumpad(anchor: HTMLElement, timeInput: HTMLInputElement): void {
this.tinyClosePopover?.();
// 0.122.3 (#4): build the popover + bind its listeners in the ANCHOR's
// document so it lands in the same (possibly popout) window as the modal —
// not always the main window.
const doc = anchor.ownerDocument ?? document;
const win = doc.defaultView ?? window;
// Seed from the current time value, else the current clock time.
let h24 = 9, mm = 0;
if (timeInput.value) {
@ -1751,15 +1756,15 @@ export class DueDatePickerModal extends Modal {
const period: "am" | "pm" = h24 >= 12 ? "pm" : "am";
const seedH = h24 === 0 ? 12 : (h24 > 12 ? h24 - 12 : h24);
const pop = document.body.createDiv({ cls: "stashpad-when-popover stashpad-due-time-pop" });
const pop = doc.body.createDiv({ cls: "stashpad-when-popover stashpad-due-time-pop" });
// Above the modal (Obsidian modals sit ~var(--layer-modal)).
pop.setCssStyles({ position: "fixed", zIndex: "9999" });
let onEnter: (() => void) | null = null;
const close = (): void => {
pop.remove();
document.removeEventListener("mousedown", outside, true);
document.removeEventListener("keydown", onKey, true);
doc.removeEventListener("mousedown", outside, true);
doc.removeEventListener("keydown", onKey, true);
if (this.tinyClosePopover === close) this.tinyClosePopover = null;
};
const outside = (e: MouseEvent): void => {
@ -1782,12 +1787,12 @@ export class DueDatePickerModal extends Modal {
const rect = anchor.getBoundingClientRect();
pop.setCssStyles({
left: `${Math.max(8, Math.min(rect.left, window.innerWidth - 220))}px`,
left: `${Math.max(8, Math.min(rect.left, win.innerWidth - 220))}px`,
top: `${rect.bottom + 4}px`,
});
setTimeout(() => {
document.addEventListener("mousedown", outside, true);
document.addEventListener("keydown", onKey, true);
doc.addEventListener("mousedown", outside, true);
doc.addEventListener("keydown", onKey, true);
}, 0);
}

View file

@ -104,6 +104,19 @@ export class NoteBodyRenderer {
return !!c && c.mtime === file.stat.mtime;
}
/** 0.122.6 (#13): drop a file's cached render so the next render recomputes
* from fresh content. Wired to the modify event. The mtime-keyed cache can be
* poisoned: a render that runs while `cachedRead` is momentarily stale seen
* on a network drive or after an external/coworker edit stamps the NEW
* mtime onto OLD content, so it then serves a truncated / attachment-less body
* until reload. Evicting on modify forces a recompute after the render
* debounce (by when cachedRead is fresh), breaking that sticky-stale state. */
evict(file: TFile): void {
const c = this.renderCache as { evict?: (p: string) => void; delete?: (p: string) => void };
if (c.evict) c.evict(file.path);
else if (c.delete) c.delete(file.path);
}
/** Register a deferred render for a cold row: run `fn` once the container
* nears the viewport. */
defer(container: HTMLElement, fn: () => void): void {

View file

@ -1345,6 +1345,10 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
): void => {
if (currentPopoverClose) currentPopoverClose();
const pop = anchor.createDiv({ cls: "stashpad-when-popover" });
// 0.122.3 (#4): bind the outside-click listener on the ANCHOR's document
// so it works when the search modal is in a popout window (the popover
// itself is already a child of `anchor`, so it renders in that window).
const doc = anchor.ownerDocument ?? document;
// 0.69.34: parent = chipScope's parent (this.scope) so unhandled
// keys (arrows etc) cascade to SuggestModal's handlers, same
// reasoning as chipScope above.
@ -1359,7 +1363,7 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
if (closed) return;
closed = true;
pop.remove();
document.removeEventListener("mousedown", outside, true);
doc.removeEventListener("mousedown", outside, true);
try { (this.app as any).keymap?.popScope(popScope); } catch { /* ignore */ }
if (currentPopoverClose === close) currentPopoverClose = null;
};
@ -1393,7 +1397,7 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
// Belt-and-suspenders: if the modal closes with this popover
// still open, ensure the scope is popped on close.
this.pendingCleanups.push(close);
setTimeout(() => document.addEventListener("mousedown", outside, true), 0);
setTimeout(() => doc.addEventListener("mousedown", outside, true), 0);
};
tfBtn.addEventListener("click", (e) => {

View file

@ -85,12 +85,16 @@ export function buildTimePickerInto(pop: HTMLElement, opts: BuildTimePickerOpts)
};
const clamp = (el: HTMLInputElement): void => {
// 0.122.7 (#11): keep EXACTLY what was typed (incl. leading zeros like
// "00" / "09") so the field renders it — only rewrite when it's out of
// range (hours > 24 → 24, minutes > 59 → 59). Previously `String(n)`
// collapsed "00" → "0", so a zero could never be seen/entered.
const v = el.value.replace(/\D/g, "").slice(0, 2);
if (v === "") { el.value = ""; if (el === hField) syncAmpmEnabled(); return; }
let n = parseInt(v, 10);
if (el === hField) { if (n > 24) n = 24; }
else { if (n > 59) n = 59; }
el.value = String(n);
el.value = v;
if (v === "") { if (el === hField) syncAmpmEnabled(); return; }
const n = parseInt(v, 10);
const max = el === hField ? 24 : 59;
if (n > max) el.value = String(max);
if (el === hField) syncAmpmEnabled();
};
for (const el of [hField, mField]) el.addEventListener("input", () => clamp(el));

View file

@ -705,6 +705,7 @@ export class StashpadView extends ItemView {
private renderSuppressed(): boolean {
return this.bulkRenderDepth > 0
|| this.bulkSettleTimer != null
|| this.autoSyncDeferActive
|| this.plugin.rebootstrapInProgress
|| this.plugin.okfRebuildingFolders.has(this.noteFolder);
}
@ -731,6 +732,48 @@ export class StashpadView extends ItemView {
}, settleMs);
}
// --- Auto sync-burst deferral (0.122.8, F7) -----------------------------
// Obsidian Sync (and any external bulk write) fires a stream of `modify`/
// `create` events that none of the explicit bulk flags above cover — each
// one would otherwise repaint the list, which on mobile makes it unusable
// for the duration of a sync. Detect a burst of file events, engage the
// same render suppression, surface a notice, and repaint ONCE when it
// settles.
private syncBurstTimes: number[] = [];
private autoSyncDeferActive = false;
private autoSyncSettleTimer: number | null = null;
private autoSyncNotice: Notice | null = null;
/** Record an external file event; returns true when the render should be
* deferred (a burst is in progress). Resets the settle timer on every
* event so the notice stays up for the whole sync, then clears. */
private deferDuringSyncBurst(): boolean {
// Rebootstrap has its own suppression + one-shot repaint flow — don't
// double up a notice on top of it.
if (this.plugin.rebootstrapInProgress) return false;
const now = Date.now();
this.syncBurstTimes.push(now);
const cutoff = now - 2000;
while (this.syncBurstTimes.length && this.syncBurstTimes[0] < cutoff) this.syncBurstTimes.shift();
if (!this.autoSyncDeferActive && this.syncBurstTimes.length >= 6) {
this.autoSyncDeferActive = true;
this.autoSyncNotice = new Notice("Stashpad: syncing — list updates paused until it settles…", 0);
}
if (!this.autoSyncDeferActive) return false;
if (this.autoSyncSettleTimer != null) window.clearTimeout(this.autoSyncSettleTimer);
this.autoSyncSettleTimer = window.setTimeout(() => this.endAutoSyncDefer(), 1500);
return true;
}
private endAutoSyncDefer(): void {
this.autoSyncSettleTimer = null;
this.autoSyncDeferActive = false;
this.syncBurstTimes = [];
this.autoSyncNotice?.hide();
this.autoSyncNotice = null;
this.forceReconcileRender();
}
/** Public: a one-shot rebuild + render. Used by the plugin after rebootstrap
* clears `rebootstrapInProgress` to repaint the (suppressed) view once. */
forceReconcileRender(): void {
@ -830,6 +873,9 @@ export class StashpadView extends ItemView {
this.focusedMiniObserver?.disconnect();
this.focusedMiniObserver = null;
if (this.treeReconcileTimer != null) { window.clearTimeout(this.treeReconcileTimer); this.treeReconcileTimer = null; }
if (this.autoSyncSettleTimer != null) { window.clearTimeout(this.autoSyncSettleTimer); this.autoSyncSettleTimer = null; }
this.autoSyncNotice?.hide();
this.autoSyncNotice = null;
this.composerAutocomplete?.detach();
this.composerAutocomplete = null;
for (const d of this.slugDebouncers.values()) d.cancel();
@ -3813,10 +3859,12 @@ export class StashpadView extends ItemView {
* caller can close the wrapping popover/accordion. */
private populateViewMenuBody(container: HTMLElement, onPicked: () => void): void {
const current = this.currentViewMode();
const addRow = (mode: ViewMode, desc: string): void => {
const addRow = (mode: ViewMode, desc: string, icon: string): void => {
const row = container.createDiv({ cls: "stashpad-view-popover-row" });
if (mode === current) row.addClass("is-active");
const main = row.createDiv({ cls: "stashpad-view-popover-main" });
// 0.122.7: leading icon (matches the View dropdown button's icons).
setIcon(main.createSpan({ cls: "stashpad-view-popover-icon" }), icon);
main.createSpan({ cls: "stashpad-view-popover-label", text: VIEW_MODE_LABELS[mode] });
row.createDiv({ cls: "stashpad-view-popover-desc", text: desc });
row.onclick = async (e) => {
@ -3828,9 +3876,12 @@ export class StashpadView extends ItemView {
this.render();
};
};
addRow("nested", "Tree of immediate children (default).");
addRow("flat", "All descendants of the current focus, flat by sort.");
addRow("everything", "All descendants PLUS non-Stashpad files in the folder.");
// 0.122.7: dividers between each mode for clearer separation.
addRow("nested", "Tree of immediate children (default).", "list-tree");
container.createDiv({ cls: "stashpad-view-popover-divider" });
addRow("flat", "All descendants of the current focus, flat by sort.", "list");
container.createDiv({ cls: "stashpad-view-popover-divider" });
addRow("everything", "All descendants PLUS non-Stashpad files in the folder.", "layout-grid");
container.createDiv({ cls: "stashpad-view-popover-divider" });
@ -3955,8 +4006,12 @@ export class StashpadView extends ItemView {
const a = parseAuthorRef(this.app.metadataCache.getFileCache(f)?.frontmatter?.author);
if (a) authors.set(a.id, a.name);
}
const authRow = container.createDiv({ cls: "stashpad-view-popover-row stashpad-view-popover-toggle" });
// 0.122.7: divider above "By author" + a leading icon; dropped the
// -toggle class so it isn't indented like the checkbox rows.
container.createDiv({ cls: "stashpad-view-popover-divider" });
const authRow = container.createDiv({ cls: "stashpad-view-popover-row" });
const authMain = authRow.createDiv({ cls: "stashpad-view-popover-main" });
setIcon(authMain.createSpan({ cls: "stashpad-view-popover-icon" }), "user");
authMain.createSpan({ cls: "stashpad-view-popover-label", text: "By author" });
const authSel = authMain.createEl("select", { cls: "stashpad-view-author-select" });
const allO = authSel.createEl("option", { text: "All authors", value: "" });
@ -4729,6 +4784,9 @@ export class StashpadView extends ItemView {
if (!node.file) return;
const file = node.file;
const wrap = parent.createDiv({ cls: "stashpad-focused" });
// 0.122.2 (#9): the focused-note header gets the same right-click menu as a
// list row (it IS a note — Copy/Cut/Move/Task/Delete all apply to it).
wrap.oncontextmenu = (evt) => { evt.preventDefault(); this.openNoteMenu(evt, node); };
// meta column: timestamp + a transparent grip-shaped spacer so the
// body's left edge column-aligns with each list row's body.
@ -8624,6 +8682,9 @@ export class StashpadView extends ItemView {
this.render(policy);
this.refreshHeaderTitle();
this.viewRoot.focus({ preventScroll: true });
// 0.122.7 (F2): back/forward changed the cursor — notify so the detail panel
// re-resolves to the new selection instead of staying pinned to the old one.
this.plugin.notifyStashpadSelectionChanged();
}
private navigateUp(): void {
@ -8676,6 +8737,10 @@ export class StashpadView extends ItemView {
this.refreshHeaderTitle();
// Belt-and-suspenders reveal in the fallback case.
if (idx < 0) this.revealCursorRow();
// 0.122.7 (F2): navigating OUT moves the cursor to the note we exited;
// notify so the detail panel (pinned to the child we were on) re-resolves to
// this new selection (the parent) instead of staying on the stale child.
this.plugin.notifyStashpadSelectionChanged();
}
private openBookmarks(): void {
const bookmarks = (this.app as any).internalPlugins?.plugins?.bookmarks?.instance?.items ?? [];
@ -10917,6 +10982,11 @@ export class StashpadView extends ItemView {
private onFileModify = (file: TFile): void => {
if (!(file instanceof TFile) || file.extension !== "md") return;
if (!file.path.startsWith(this.noteFolder + "/")) return;
// 0.122.6 (#13): drop this file's (possibly stale-content-but-fresh-mtime)
// render-cache entry so the debounced re-render below recomputes from fresh
// content — fixes the truncated/attachment-less "earlier version" render
// that stuck until reload (network drive / external edits).
this.bodyRenderer.evict(file);
this.scheduleSlugRename(file);
this.scheduleAttachmentSync(file);
// 0.72.4: classify self vs external and queue the contributor stamp.
@ -10926,11 +10996,15 @@ export class StashpadView extends ItemView {
// metadataCache hook only fires for metadata-affecting edits — pure
// body changes (e.g. pasting a long block of plain text) wouldn't
// otherwise trigger a re-render, leaving stale clamp state.
// 0.122.8 (F7): during a sync/bulk-write burst, hold the repaint and let
// deferDuringSyncBurst do one render when it settles.
if (this.deferDuringSyncBurst()) return;
this.debouncedRender();
};
private onFileCreate = (file: TFile): void => {
if (!(file instanceof TFile) || file.extension !== "md") return;
if (!file.path.startsWith(this.noteFolder + "/")) return;
if (this.deferDuringSyncBurst()) return;
this.debouncedRender();
};
@ -11150,11 +11224,18 @@ export class StashpadView extends ItemView {
if (!this.selection.has(node.id)) { this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id; }
void this.cmdClone();
}));
// 0.122.2 (#9): copy the note's text, or cut the whole note (for paste/move).
// `focusClicked` (defined below) normalises selection to the right-clicked row.
menu.addItem((it: any) => it.setTitle("Copy text").setIcon("copy").onClick(() => { focusClicked(); void this.cmdCopy(); }));
// 0.122.7: "Cut note" pulled from the menu for now — cut/paste has known bugs
// and cutting a parent/home note from the context menu is too easy a footgun.
// Still available via the cutNotes hotkey. (See ui-polish todos.)
menu.addItem((it: any) => it.setTitle("Fork into a separate note…").setIcon("git-branch").onClick(() => {
if (!this.selection.has(node.id)) { this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id; }
this.cmdForkNote();
}));
menu.addItem((it: any) => it.setTitle("Insert template…").setIcon("file-plus-2").onClick(() => this.cmdInsertTemplate()));
// 0.122.2 (#9): "Insert template…" removed from the right-click menu to keep
// it compact — still available via command palette + its hotkey.
menu.addItem((it: any) => it.setTitle("Export to .stash").setIcon("package").onClick(() => {
// Multi-select normalisation (matches Clone / Delete / Set color):
// if the right-clicked row isn't in the selection, treat the
@ -11235,6 +11316,9 @@ export class StashpadView extends ItemView {
const isDone = this.isCompleted(node);
target.addItem((it: any) => it.setTitle(isDone ? "Mark incomplete" : "Mark complete").setIcon(isDone ? "circle" : "check-circle").onClick(() => { focusClicked(); void this.cmdToggleComplete(); }));
} else {
// 0.122.2 (#10): let non-tasks be marked complete too (sets `completed`;
// the note then counts as a task via the bare-completed field).
target.addItem((it: any) => it.setTitle("Mark complete").setIcon("check-circle").onClick(() => { focusClicked(); void this.toggleCompletedForNode(node); }));
target.addItem((it: any) => it.setTitle("Turn into task").setIcon("check-square").onClick(() => { focusClicked(); void this.cmdToggleTask(); }));
}
target.addItem((it: any) => it.setTitle("Assign / schedule…").setIcon("user-plus").onClick(() => { focusClicked(); this.cmdAssign(); }));

View file

@ -471,8 +471,18 @@
font-weight: 600;
}
.stashpad-view-popover-main { display: flex; align-items: center; gap: 8px; }
/* 0.122.7: leading icon on the view-menu mode rows + "By author". */
.stashpad-view-popover-icon { display: inline-flex; align-items: center; flex: 0 0 auto; color: var(--text-muted); }
.stashpad-view-popover-icon .svg-icon { width: 15px; height: 15px; }
.stashpad-view-popover-row.is-active .stashpad-view-popover-icon { color: var(--interactive-accent); }
.stashpad-view-popover-label { white-space: nowrap; }
.stashpad-view-popover-desc {
/* 0.122.1: descriptions are hidden everywhere now (were mobile-only) to keep
the view/filter menu compact it was getting cut off when the header
collapses into the combined menu at narrow widths. The full, expanded
explanations live in docs/public/view-menu-guide.md; the View dropdown
button still carries per-mode tooltips. */
display: none;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
white-space: normal;
@ -950,6 +960,10 @@
dark background in light mode. Inherit the notice's text color instead, which
is guaranteed to contrast its background in any theme. */
.stashpad-progress-done .stashpad-progress-sub { color: inherit; opacity: 0.9; }
/* 0.122.0 (#5): the IN-PROGRESS sub line was --text-muted (grey/dim) same
legibility problem on the notice's dark background. Inherit the notice color
so the initial "Counting notes… / <label> · N%" reads bright like the rest. */
.stashpad-progress-notice .stashpad-progress-sub { color: inherit; opacity: 0.85; }
.stashpad-progress-done .stashpad-progress-title { color: var(--text-success, var(--interactive-accent)); }
.stashpad-progress-error .stashpad-progress-title { color: var(--text-error); }
/* 0.117.0: "jump to level" button pinned at the right end of the breadcrumb
@ -2885,6 +2899,54 @@ body.is-mobile .stashpad-jd-actions .setting-item-control {
.stashpad-settings-section:last-child {
padding-bottom: 16px;
}
/* 0.122.4 (#7): at narrow widths a setting's control (especially multi-input
ones like the folder-icon picker: dropdown + text + preview) squeezed the
description down to ~1 character. Let the control WRAP below the info instead,
and keep the info from collapsing. Scoped to Stashpad's settings sections. */
.stashpad-settings-section .setting-item {
flex-wrap: wrap;
row-gap: 8px;
}
.stashpad-settings-section .setting-item .setting-item-info {
/* don't let the name+description collapse (it was wrapping to ~1ch). */
min-width: 50%;
flex: 1 1 auto;
}
.stashpad-settings-section .setting-item .setting-item-control {
/* 0.122.7: keep the control at its natural size so it WRAPS to its own
full-width line when the row is too narrow instead of shrinking its
inputs and squeezing the description. */
flex-shrink: 0;
max-width: 100%;
justify-content: flex-start;
}
/* 0.121.14: Obsidian 1.13's MOBILE settings use a 20px horizontal gutter
(measured: .setting-item padding-left = 20px). Several stashpad insets were
still at the older 14px, so they didn't line up with Obsidian's own rows
the "nothing is consistent" problem. Bump EVERY settings inset to 20px on
mobile so it all aligns. Desktop keeps its existing gutter. */
body.is-mobile .stashpad-settings-section > p,
body.is-mobile .stashpad-settings-section > h2,
body.is-mobile .stashpad-settings-section > h3,
body.is-mobile .stashpad-settings-section > h4,
body.is-mobile .stashpad-settings-section > ol,
body.is-mobile .stashpad-settings-section > ul,
body.is-mobile .stashpad-settings-section > .setting-item-description,
body.is-mobile .stashpad-folder-list,
body.is-mobile .stashpad-authored-folders-list,
body.is-mobile .stashpad-folder-placement-group,
body.is-mobile .stashpad-known-authors-list,
body.is-mobile .stashpad-encryption-section > .setting-item-description,
body.is-mobile .stashpad-okf-howto,
body.is-mobile .stashpad-beta-row {
padding-left: 20px;
padding-right: 20px;
}
body.is-mobile .stashpad-ai-disclaimer,
body.is-mobile .stashpad-okf-cta {
margin-left: 20px;
margin-right: 20px;
}
.stashpad-ai-disclaimer {
margin: 4px 14px 12px; padding: 10px 12px; border-radius: var(--radius-m, 6px);
border: 1px solid var(--color-red, #e24b4a);
@ -3393,6 +3455,42 @@ body.is-mobile .stashpad-jd-actions .setting-item-control {
border-bottom: 1px solid var(--background-modifier-border);
font-size: 0.85em;
}
/* 0.122.4 (#1): when the window is FRAMELESS (Obsidian draws min/max/close
inside the content area), the tiny-header controls sit under those window
buttons. Reserve space on the controls' side: top-RIGHT on Windows/Linux,
top-LEFT on macOS. Native-frame windows (OS draws the frame) have neither
class, so they're unaffected. (Unverified on Windows reasoned from
Obsidian's mod-*/is-frameless body classes.) */
body.is-frameless.mod-windows .stashpad-tiny-header,
body.is-hidden-frameless.mod-windows .stashpad-tiny-header,
body.is-frameless.mod-linux .stashpad-tiny-header,
body.is-hidden-frameless.mod-linux .stashpad-tiny-header {
padding-right: 140px;
}
body.is-frameless.mod-macos .stashpad-tiny-header,
body.is-hidden-frameless.mod-macos .stashpad-tiny-header {
padding-left: 80px;
}
/* 0.122.9 (F3): in frameless mode the tiny-header bar sits where the OS would
otherwise expose a draggable titlebar and the reserved padding above
extends the (non-draggable) header box over the macOS traffic-light strip,
so the window could no longer be moved. Make the header itself a drag region
and exempt its interactive children so they stay clickable. Scoped to
frameless only; native-frame windows drag via the OS frame. */
body.is-frameless .stashpad-tiny-header,
body.is-hidden-frameless .stashpad-tiny-header {
-webkit-app-region: drag;
}
body.is-frameless .stashpad-tiny-header button,
body.is-frameless .stashpad-tiny-header input,
body.is-frameless .stashpad-tiny-header .stashpad-tiny-sticky,
body.is-frameless .stashpad-tiny-header .stashpad-folder-btn,
body.is-hidden-frameless .stashpad-tiny-header button,
body.is-hidden-frameless .stashpad-tiny-header input,
body.is-hidden-frameless .stashpad-tiny-header .stashpad-tiny-sticky,
body.is-hidden-frameless .stashpad-tiny-header .stashpad-folder-btn {
-webkit-app-region: no-drag;
}
/* 0.77.0-feat: tiny-window transparency button + slider popover. */
.stashpad-tiny-opacity-btn.is-active { color: var(--text-accent); }
.stashpad-tiny-opacity-popover {