0.107.1: sheet versions, list pins, note forking + task/filter polish

This commit is contained in:
Human 2026-06-16 14:47:26 -07:00
parent 0389881adc
commit 81157657fa
13 changed files with 1243 additions and 229 deletions

165
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.103.10",
"version": "0.107.1",
"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.103.10",
"version": "0.107.1",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",

29
release-notes/0.107.1.md Normal file
View file

@ -0,0 +1,29 @@
# 0.107.1 — Sheet versions, list pins, note forking
## Sheet versions
Keep multiple tabbed versions of a single note (drafts/variants) and switch between
them inline. The active version persists across reloads, is hardened against being
hidden by accident, and is gated behind a settings toggle that ships **off** by
default — turn it on under settings when you want it.
## Pin notes in the list
Pin notes to the top of a Stashpad list so they stay put above the rest. Pinned rows
are marked with a `pin` icon in the meta column, and the folder panel can reveal a
folder's Home list-pinned notes.
## Fork a note
Fork any note into a variant under a parent you choose — a quick way to branch an
idea without losing the original.
## Tasks
- Unified due-date / assignee modal with dedicated clear (×) buttons on the date and
time fields.
- Right-click task actions are grouped under a single **Task** submenu, with
context-menu gating so options only appear when relevant.
- `Remove task` renamed to `Remove from tasks`; the redundant `Set due date` entry is
dropped from the Task submenu.
## List & filtering
- Collapse or expand every note body at once, plus an **expand bodies by default**
setting.
- Reworked tags filter: a searchable, ranked, keyboard-navigable popover.

View file

@ -119,6 +119,10 @@ export class StashpadFolderPanelView extends ItemView {
/** Pin subtree expansion state (key = `folder|id`), kept across re-renders. */
private pinExpanded = new Set<string>();
/** 0.105.1: which folder rows are expanded to reveal their Home list-pinned
* notes (key = folder path), kept across re-renders. */
private folderPinExpanded = new Set<string>();
private openPinnedOptionsMenu(e: MouseEvent): void {
const cur = this.plugin.settings.folderPanelPinnedGrouping ?? "pin-order";
const menu = new Menu();
@ -456,6 +460,25 @@ export class StashpadFolderPanelView extends ItemView {
pin.setAttr("aria-label", "Pinned");
}
// 0.105.1: reveal this folder's Home list-pinned notes inline. The chevron
// shows only when the folder has any; clicking toggles a sub-list below the
// row. stopPropagation so it doesn't trigger the row's folder navigation.
const homePinned = this.folderHomePinnedNotes(folder);
const pinKey = StashpadFolderPanelView.clean(folder);
const pinsExpanded = this.folderPinExpanded.has(pinKey);
if (homePinned.length > 0) {
const chev = row.createSpan({ cls: "stashpad-folderpanel-pinreveal" });
renderCountBadge(chev, homePinned.length, pinsExpanded);
chev.setAttr("aria-label", pinsExpanded ? "Hide pinned notes" : `${homePinned.length} pinned note${homePinned.length === 1 ? "" : "s"}`);
chev.addEventListener("mousedown", (e) => { e.stopPropagation(); e.preventDefault(); });
chev.onclick = (e) => {
e.stopPropagation();
if (this.folderPinExpanded.has(pinKey)) this.folderPinExpanded.delete(pinKey);
else this.folderPinExpanded.add(pinKey);
this.render();
};
}
// 0.95.1: per-folder icon. Tinted by the folder's Home-note color when set.
// 0.98.37: archive folders show the ARCHIVE icon in place of the folder icon
// (one icon instead of folder + a separate badge).
@ -495,6 +518,28 @@ export class StashpadFolderPanelView extends ItemView {
this.onNavigateAway(); this.jumpToFolder(folder);
});
row.oncontextmenu = (e) => { e.preventDefault(); this.openFolderMenu(e, folder); };
// 0.105.1: when expanded, list the folder's Home list-pinned notes as a
// sub-list below the folder row. Clicking one reveals it in its Stashpad.
if (homePinned.length > 0 && pinsExpanded) {
const box = list.createDiv({ cls: "stashpad-folderpanel-pinlist" });
for (const f of homePinned) {
const prow = box.createDiv({ cls: "stashpad-folderpanel-pinrow" });
const fmc = (this.app.metadataCache.getFileCache(f)?.frontmatter ?? {}) as any;
const pic = prow.createSpan({ cls: "stashpad-folderpanel-pinrow-icon" });
setIcon(pic, "arrow-up-to-line");
if (typeof fmc.color === "string") pic.style.color = fmc.color;
prow.createSpan({ cls: "stashpad-folderpanel-pinrow-label", text: this.titleFromFile(f) });
prow.onclick = () => { this.onNavigateAway(); void this.plugin.revealNoteInStashpad(f); };
}
}
}
/** A folder's ROOT-level (Home) list-pinned notes backs the folder-panel
* reveal. Reuses childrenOf(folder, ROOT_ID) + the listPinned frontmatter. */
private folderHomePinnedNotes(folder: string): TFile[] {
return this.childrenOf(folder, ROOT_ID).filter((f) =>
(this.app.metadataCache.getFileCache(f)?.frontmatter as any)?.listPinned === true);
}
/** Collapsible "Hidden (N)" group at the bottom of the Folders list, so hidden

View file

@ -1034,6 +1034,16 @@ export default class StashpadPlugin extends Plugin {
name: "Toggle split-on-newlines",
callback: () => call("toggleSplit"),
});
this.addCommand({
id: "stashpad-fork-version",
name: "Fork as version (alternate draft / sheet)",
callback: () => call("cmdForkVersion"),
});
this.addCommand({
id: "stashpad-mark-version-final",
name: "Mark version as final (sheet)",
callback: () => call("cmdMarkVersionFinal"),
});
this.addCommand({
id: "stashpad-command-palette",
name: "Command palette (Stashpad only)",
@ -1190,8 +1200,11 @@ export default class StashpadPlugin extends Plugin {
// "Clone / duplicate / copy" — three synonyms in the name so command-palette
// fuzzy search hits regardless of which word the user reaches for.
this.addCommand({ id: "stashpad-clone", name: "Clone selection (duplicate / copy notes)", callback: () => call("cmdClone") });
this.addCommand({ id: "stashpad-fork-note", name: "Fork note (copy as a variant under a chosen parent)", callback: () => call("cmdForkNote") });
this.addCommand({ id: "stashpad-insert-template", name: "Insert template (clone an existing note)", callback: () => call("cmdInsertTemplate") });
this.addCommand({ id: "stashpad-toggle-expand", name: "Show more / show less (expand toggle)", callback: () => call("cmdToggleExpand") });
this.addCommand({ id: "stashpad-expand-all", name: "Expand all (show every note's full body)", callback: () => call("cmdExpandAll") });
this.addCommand({ id: "stashpad-collapse-all", name: "Collapse all (clamp every note's body)", callback: () => call("cmdCollapseAll") });
// Three view-level keybinds that previously had no command-palette
// entry. Names mirror their COMMAND_META labels for fuzzy lookup.
this.addCommand({ id: "stashpad-pick-move", name: "Move (in-list, arrow + Enter)", callback: () => call("cmdInListPicker") });
@ -1333,6 +1346,7 @@ export default class StashpadPlugin extends Plugin {
}
this.addCommand({ id: "stashpad-swap-with-parent", name: "Swap with parent (ouroboros)", callback: () => call("cmdSwapWithParent") });
this.addCommand({ id: "stashpad-toggle-pin", name: "Pin / unpin selected note (sidebar)", callback: () => call("cmdTogglePin") });
this.addCommand({ id: "stashpad-list-pin", name: "Pin / unpin to top of list", callback: () => call("cmdToggleListPin") });
// 0.61.1: tiny mode — opens a popout window with the minimal shell
// (folder/focus title + list + composer + sticky/expand controls).
this.addCommand({
@ -4875,6 +4889,38 @@ export default class StashpadPlugin extends Plugin {
}
}
// Sheet versions: which version (note id) of each `sheet:` group is the one
// shown as a row. Persisted to localStorage (like last-cursor) so the choice
// survives reloads. Keyed by group id (globally unique) within a folder.
// Storage key: "stashpad:active-versions" → { "<folder>": { "<groupId>": "<noteId>" } }
private readonly ACTIVE_VERSIONS_LS_KEY = "stashpad:active-versions";
private readActiveVersionsFile(): Record<string, Record<string, string>> {
try {
const raw = window.localStorage.getItem(this.ACTIVE_VERSIONS_LS_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed as Record<string, Record<string, string>> : {};
} catch {
return {};
}
}
/** Map of <groupId> → <active version note id> for the given folder. */
loadActiveVersions(folder: string): Map<string, string> {
const all = this.readActiveVersionsFile();
return new Map(Object.entries(all[folder] ?? {}));
}
/** Synchronously persist the active version for one (folder, group). */
saveActiveVersion(folder: string, groupId: string, noteId: string): void {
try {
const all = this.readActiveVersionsFile();
if (!all[folder]) all[folder] = {};
all[folder][groupId] = noteId;
window.localStorage.setItem(this.ACTIVE_VERSIONS_LS_KEY, JSON.stringify(all));
} catch (e) {
console.warn("[Stashpad] failed to save active-version", e);
}
}
/** Serializes ALL settings writes so a fast draft-write can't race with
* a settings-tab edit and clobber a freshly-changed shortcut. Both
* saveSettings() and persistSettingsQuiet() funnel through here. */

View file

@ -13,6 +13,9 @@ export interface DuePickerOptions {
knownAuthors?: AssigneeRef[];
/** Assignees already on the note, to pre-fill the chips. */
currentAssignees?: AssigneeRef[];
/** Modal title. Defaults to "Set due date". The "Assign to" command
* opens this same modal with a different title. */
title?: string;
}
import type { NotificationCategory, NotificationRecord, NotificationService } from "./notifications";
// Obsidian types `moment` as the namespace (not callable); a callable view.
@ -1519,36 +1522,6 @@ export function buildAssigneePicker(
/** 0.78.3: standalone "Assign to" modal assignment without touching the
* due date. onPick gets the chosen assignee set; not called on dismiss. */
export class AssignModal extends Modal {
private didChoose = false;
private assignees: AssigneeRef[] = [];
constructor(
app: App,
private opts: { knownAuthors: AssigneeRef[]; currentAssignees: AssigneeRef[] },
private onPick: (assignees: AssigneeRef[]) => void,
) {
super(app);
this.assignees = [...opts.currentAssignees];
}
onOpen(): void {
this.modalEl?.addClass("stashpad-compact-modal");
this.contentEl.empty();
this.titleEl.setText("Assign task");
const wrap = this.contentEl.createDiv({ cls: "stashpad-due-picker" });
buildAssigneePicker(wrap, {
knownAuthors: this.opts.knownAuthors,
initial: this.assignees,
onChange: (list) => { this.assignees = list; },
});
const row = this.contentEl.createDiv({ cls: "stashpad-modal-btns" });
const cancel = row.createEl("button", { text: "Cancel" });
cancel.onclick = () => { this.didChoose = true; this.close(); };
const ok = row.createEl("button", { cls: "mod-cta", text: "Save" });
ok.onclick = () => { this.didChoose = true; this.close(); this.onPick(this.assignees); };
}
onClose(): void { this.contentEl.empty(); void this.didChoose; }
}
export class DueDatePickerModal extends Modal {
private didChoose = false;
/** Working set of assignees, mutated by the chips UI. */
@ -1571,7 +1544,7 @@ export class DueDatePickerModal extends Modal {
onOpen(): void {
this.modalEl?.addClass("stashpad-compact-modal"); // 0.76.18
this.contentEl.empty();
this.titleEl.setText("Set due date");
this.titleEl.setText(this.opts.title ?? "Set due date");
// Pre-fill from the current value when parseable.
let initial: Date | null = null;
@ -1589,10 +1562,17 @@ export class DueDatePickerModal extends Modal {
const dateIcon = dateField.createSpan({ cls: "stashpad-due-field-icon" });
setIcon(dateIcon, "calendar");
const dateInput = dateField.createEl("input", { type: "date", cls: "stashpad-due-date" });
// 0.104.x: dedicated × to clear this field independently.
const dateClear = dateField.createSpan({ cls: "stashpad-due-clear", attr: { "aria-label": "Clear date" } });
setIcon(dateClear, "x");
dateClear.onclick = () => { dateInput.value = ""; dateInput.focus(); };
const timeField = fields.createDiv({ cls: "stashpad-due-field" });
const timeIcon = timeField.createSpan({ cls: "stashpad-due-field-icon" });
setIcon(timeIcon, "clock");
const timeInput = timeField.createEl("input", { type: "time", cls: "stashpad-due-time" });
const timeClear = timeField.createSpan({ cls: "stashpad-due-clear", attr: { "aria-label": "Clear time" } });
setIcon(timeClear, "x");
timeClear.onclick = () => { timeInput.value = ""; timeInput.focus(); };
// 0.76.8: the leading icon IS the picker button. The native
// ::-webkit-calendar-picker-indicator (on the input's right) is
// hidden via CSS; clicking our left icon opens the OS picker via

View file

@ -55,12 +55,12 @@ export type CommandId =
| "toggleSplit" | "pickDestination" | "search" | "searchInParent" | "delete" | "undo" | "redo"
| "toggleComplete" | "moveUp" | "moveDown" | "moveToTop" | "moveToBottom"
| "outdent" | "setColor"
| "clone" | "insertTemplate"
| "toggleExpand"
| "clone" | "forkNote" | "insertTemplate"
| "toggleExpand" | "expandAll" | "collapseAll"
| "exportStash" | "importStash" | "pickFolder"
| "cloneStashpadTab" | "selectAll" | "copyCodeBlock"
| "swapWithParent"
| "togglePin"
| "togglePin" | "listPin"
| "toggleTask" | "setDue"
| "jumpToTop" | "jumpToBottom"
| "lockSelection" | "unlockAll" | "moveToArchive" | "encryptDelete"
@ -119,8 +119,11 @@ export const COMMAND_META: CommandMeta[] = [
{ id: "outdent", label: "Outdent (move to grandparent)", desc: "Default: Mod+[ — re-parents the selection one level up.", defaultPrimary: "Mod+[" },
{ id: "setColor", label: "Set note color", desc: "Default: Shift+: or ; — open the color picker for the selection (both chords active).", defaultPrimary: "Shift+:", defaultSecondary: ";", defaultUseBoth: true },
{ id: "clone", label: "Clone (duplicate / copy) selection", desc: "Default: Mod+Shift+D — clone selected notes (with their subtrees) as siblings.", defaultPrimary: "Mod+Shift+D" },
{ id: "forkNote", label: "Fork note (copy under a chosen parent)", desc: "Duplicate the cursor row (with its subtree) as a variant and pick which parent it nests under. No default chord.", defaultPrimary: "" },
{ id: "insertTemplate", label: "Insert template (clone an existing note)", desc: "Pick any note in this Stashpad; clone it (with subtree + attachments) into the current view, retimestamped.", defaultPrimary: "" },
{ id: "toggleExpand", label: "Show more / show less (expand toggle)", desc: "Default: Shift+? — toggle the clamp on the cursor row (or every selected row).", defaultPrimary: "Shift+?" },
{ id: "expandAll", label: "Expand all (show every note's full body)", desc: "Un-clamp every note in the current list at once.", defaultPrimary: "" },
{ id: "collapseAll", label: "Collapse all (clamp every note's body)", desc: "Re-clamp every note in the current list at once.", defaultPrimary: "" },
{ id: "exportStash", label: "Export selection to .stash", desc: "Export the selected subtree(s) as a .stash bundle (notes + attachments).", defaultPrimary: "" },
{ id: "importStash", label: "Import .stash file", desc: "Open the .stash bundle picker and import its notes into this Stashpad.", defaultPrimary: "" },
{ id: "pickFolder", label: "Open / switch / create Stashpad folder", desc: "Default: Mod+S — opens the unified folder picker (reveal, switch, create, convert).", defaultPrimary: "Mod+S" },
@ -129,6 +132,7 @@ export const COMMAND_META: CommandMeta[] = [
{ id: "copyCodeBlock", label: "Copy code from codeblock", desc: "Default: { — copy the contents of the cursor row's first codeblock (or pick one when multiple exist).", defaultPrimary: "{" },
{ id: "swapWithParent", label: "Swap with parent (ouroboros)", desc: "Promote the cursor row above its current parent; the parent slides under it (carrying its other children). No default — bind in this tab.", defaultPrimary: "" },
{ id: "togglePin", label: "Pin / unpin selected note", desc: "Default: P — toggle the sidebar pin state of the cursor row (or focused note).", defaultPrimary: "P" },
{ id: "listPin", label: "Pin / unpin to top of list", desc: "Float the cursor row (or selection) to the TOP of its list — distinct from the sidebar pin. No default chord.", defaultPrimary: "" },
{ id: "toggleTask", label: "Toggle task (todo)", desc: "Default: H — mark the selection (or cursor row) as a task / todo, or clear it. Tasks appear in the Tasks panel.", defaultPrimary: "H" },
{ id: "setDue", label: "Set due date…", desc: "Default: D — open a date+time picker to set (or clear) the due date on the selection. Setting a due date also marks the note as a task.", defaultPrimary: "D" },
{ id: "jumpToTop", label: "Jump to top of list", desc: "Default: Home — move the cursor to the first note in the current list.", defaultPrimary: "Home" },
@ -386,6 +390,12 @@ export interface StashpadSettings {
* effect that vanishes the moment the cursor moves. Off by
* default. */
autoExpandCursorRow: boolean;
/** When on, note bodies render fully expanded by default; the
* per-note "Show more / show less" toggle and the expand/collapse-all
* commands then act as a *collapse* opt-out (the expandedNotes Set is
* interpreted as "differs from this default"). Off = current behavior
* (bodies clamp by default, expand is opt-in). */
expandBodiesByDefault: boolean;
/** 0.74.1: auto-open the right-sidebar detail panel whenever a
* Stashpad view becomes active. Off by default opt in via this
* toggle or the matching palette command. */
@ -394,6 +404,11 @@ export interface StashpadSettings {
* focus/open it navigate into it, same as ArrowRight or the
* enter arrow. On by default. Single click still just selects. */
doubleClickToFocus: boolean;
/** 0.107.0: enable "Sheet versions" treat notes sharing a `sheet-group`
* frontmatter id as alternate versions of one item (only the active one
* shows as a row; siblings collapse into a tab bar). Off by default so the
* collapse filter never touches anyone who hasn't opted in. */
enableSheetVersions: boolean;
/** 0.76.6: how dates (due dates, created/modified) display across
* the Tasks panel + detail panel. One of locale / iso / us / eu /
* long. Default "locale". */
@ -522,8 +537,10 @@ export const DEFAULT_SETTINGS: StashpadSettings = {
autoNavOnMoveIn: false,
autoNavOnMoveOut: false,
autoExpandCursorRow: false,
expandBodiesByDefault: false,
autoOpenDetailPanel: false,
doubleClickToFocus: true,
enableSheetVersions: false,
dateDisplayFormat: "locale",
dateDisplayTimezone: "",
jdIndexScope: "vault",
@ -943,10 +960,14 @@ export class StashpadSettingTab extends PluginSettingTab {
() => this.plugin.settings.autoNavOnMoveOut, (v) => { this.plugin.settings.autoNavOnMoveOut = v; }, ["navigate", "move", "out"]));
items.push(toggle("Double-click a note to open it", "Double-click (or double-tap on mobile) a note in the list to focus/open it — the same as pressing → or clicking the enter arrow. Single click still just selects. On by default.",
() => this.plugin.settings.doubleClickToFocus, (v) => { this.plugin.settings.doubleClickToFocus = v; }, ["double", "click", "open", "focus"]));
items.push(toggle("Sheet versions (alternate drafts)", "Treat notes that share a 'sheet-group' frontmatter id as alternate versions of one item: only the active version shows as a row, and its siblings collapse into a tab bar at the bottom of that row. Use \"Fork as version\" on a note to start. Off by default — when off, no note is ever hidden by this feature and the commands do nothing.",
() => this.plugin.settings.enableSheetVersions, (v) => { this.plugin.settings.enableSheetVersions = v; }, ["sheet", "version", "draft", "alternate", "fork"]));
items.push(toggle("Auto-open the detail panel", "Open the right-sidebar Stashpad detail panel automatically whenever a Stashpad view becomes active. The panel shows the cursored note's body, metadata, and children. Off = open manually via ribbon or command palette.",
() => this.plugin.settings.autoOpenDetailPanel, (v) => { this.plugin.settings.autoOpenDetailPanel = v; }, ["detail", "panel", "sidebar"]));
items.push(toggle("Expand the cursor row's body automatically", "As you arrow-key through the list, the row under the cursor temporarily un-clamps to show its full body. Moving away re-collapses it. Doesn't affect the persistent 'Show more' state — this is a transient view-only effect.",
() => this.plugin.settings.autoExpandCursorRow, (v) => { this.plugin.settings.autoExpandCursorRow = v; }, ["expand", "cursor", "body"]));
items.push(toggle("Expand note bodies by default", "Show every note's full body by default instead of clamping long notes. The per-note 'Show more / show less' toggle and the Expand-all / Collapse-all commands then work in reverse — they let you collapse individual notes back down. Off = bodies clamp by default (expand is opt-in).",
() => this.plugin.settings.expandBodiesByDefault, (v) => { this.plugin.settings.expandBodiesByDefault = v; }, ["expand", "collapse", "default", "body", "clamp"]));
items.push(toggle("Confirm cross-parent drag-and-drop", "When dragging notes onto a note that has a different parent, ask before re-parenting (turn off to allow direct moves).",
() => this.plugin.settings.confirmCrossParentDrag, (v) => { this.plugin.settings.confirmCrossParentDrag = v; }, ["confirm", "drag", "drop", "reparent"]));
items.push(toggle("Confirm bulk deletes", "Warn before deletes that affect more than one note — multi-selection delete OR deleting a note that has descendants. A single childless note with no attachments never prompts. Off = those deletes apply immediately (undo still recovers everything).",

90
src/sheets-versions.ts Normal file
View file

@ -0,0 +1,90 @@
// Sheet "versions" — several notes sharing a `sheet-group` id are treated as
// alternate versions of one item. Only the active version shows as a row; its
// siblings collapse into a small tab bar on that row. The frontmatter keys are
// shared with the standalone Sheets plugin so the same notes work in both.
//
// The group key is `sheet-group` (NOT the generic `sheet`) to avoid colliding
// with a user's own unrelated frontmatter, AND a note only counts as a version
// when it carries BOTH `sheet-group` and a numeric `sheet-order` (see
// isVersionMember) — so a stray single key can never silently hide a note.
import { App, TFile } from "obsidian";
import { TreeNode } from "./types";
export const SHEET_KEY = "sheet-group"; // group id shared by all versions (string)
export const SHEET_ORDER_KEY = "sheet-order"; // numeric order within the group
export const SHEET_FINAL_KEY = "sheet-final"; // true on the chosen version
export const SHEET_LABEL_KEY = "sheet-label"; // optional custom tab label
type Fm = Record<string, unknown> | null | undefined;
export function sheetIdOf(fm: Fm): string | null {
if (!fm) return null;
const v = fm[SHEET_KEY];
return typeof v === "string" && v.trim() ? v : null;
}
export function sheetOrderOf(fm: Fm): number | null {
const v = fm?.[SHEET_ORDER_KEY];
return typeof v === "number" ? v : null;
}
/** A note is a version-group member only when it carries BOTH a group id and a
* numeric order. Requiring two keys makes accidental collisions (a user with
* an unrelated `sheet-group` key) essentially impossible the collapse filter
* ignores anything that isn't a deliberate, fully-stamped version. */
export function isVersionMember(fm: Fm): boolean {
return sheetIdOf(fm) !== null && sheetOrderOf(fm) !== null;
}
export function sheetIsFinal(fm: Fm): boolean {
return fm?.[SHEET_FINAL_KEY] === true;
}
export function sheetLabelOf(fm: Fm): string | null {
const v = fm?.[SHEET_LABEL_KEY];
return typeof v === "string" && v.trim() ? v : null;
}
/** Generate a fresh, reasonably-unique group id (matches the Sheets plugin). */
export function newSheetGroupId(): string {
const t = Date.now().toString(36);
const r = Math.floor(Math.random() * 1e6).toString(36);
return `s-${t}-${r}`;
}
/** Read a node's frontmatter via the metadata cache. */
export function nodeFm(app: App, node: TreeNode): Fm {
if (!node.file) return null;
return app.metadataCache.getFileCache(node.file)?.frontmatter as Fm;
}
/** Sort group members by order, then created, then id — stable + deterministic. */
export function sortVersions(app: App, members: TreeNode[]): TreeNode[] {
return members.slice().sort((a, b) => {
const oa = sheetOrderOf(nodeFm(app, a));
const ob = sheetOrderOf(nodeFm(app, b));
if (oa != null && ob != null && oa !== ob) return oa - ob;
if (oa != null && ob == null) return -1;
if (oa == null && ob != null) return 1;
const ca = a.created ?? "";
const cb = b.created ?? "";
if (ca !== cb) return ca < cb ? -1 : 1;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
}
/** The default active version of a group: the final pick, else the first by
* order. `members` should already be the ones present in the current list. */
export function defaultActive(app: App, members: TreeNode[]): TreeNode | null {
if (members.length === 0) return null;
const sorted = sortVersions(app, members);
return sorted.find((m) => sheetIsFinal(nodeFm(app, m))) ?? sorted[0];
}
/** A short label for a tab: the custom label, else the note's first body line
* (the title Stashpad shows), falling back to the basename. */
export function tabTitle(app: App, node: TreeNode, fallbackTitle: string): string {
const label = sheetLabelOf(nodeFm(app, node));
if (label) return label;
return fallbackTitle;
}

View file

@ -107,6 +107,9 @@ export const RESERVED_FRONTMATTER: readonly string[] = [
// 0.86.3: sidebar pin state lives on the note (so it SYNCS with the note
// across devices). Stashpad-managed; clones/templates must not inherit it.
"pinned", "pinnedAt",
// 0.105.0: list pin — floats a note to the TOP of its sibling list (distinct
// from the sidebar pin above). Stashpad-managed; not inherited by clones.
"listPinned", "listPinnedAt",
// 0.88.0: marks a note that came in via import (used by the "imported only"
// view filter). Stashpad-managed; a clone of an imported note isn't imported.
"imported",

View file

@ -157,3 +157,51 @@ export function arraysEqual<T>(a: T[], b: T[]): boolean {
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
// --- Tag filter: special sentinel modes + ranked matching --------------------
/** Sentinel `tagFilter` values for the "has any tag" / "has no tags" filter
* modes. The `__stashpad:` prefix can't collide with a real tag name. */
export const TAG_FILTER_TAGGED = "__stashpad:tagged__";
export const TAG_FILTER_UNTAGGED = "__stashpad:untagged__";
/** Score a tag name against a query for the tag-filter search box.
* Higher = closer match; -1 = no match. Tiers (closest first):
* exact (1000) > prefix (800) > word-boundary substring (600) >
* mid-word substring (500) > subsequence/fuzzy (200).
* Substring tiers subtract the match index so earlier hits rank higher. */
export function rankTagMatch(query: string, tag: string): number {
const q = query.toLowerCase().trim();
const t = tag.toLowerCase();
if (!q) return 0;
if (t === q) return 1000;
if (t.startsWith(q)) return 800;
const idx = t.indexOf(q);
if (idx > 0) {
const prev = t[idx - 1];
const boundary = !/[a-z0-9]/.test(prev); // after a separator (-, _, /, space)
return (boundary ? 600 : 500) - Math.min(idx, 99);
}
// Subsequence fuzzy: every query char appears in order somewhere.
let qi = 0;
for (let i = 0; i < t.length && qi < q.length; i++) {
if (t[i] === q[qi]) qi++;
}
return qi === q.length ? 200 : -1;
}
/** Rank + filter a tag list by `query`. Empty query returns the list
* unchanged (caller's original frequency/alpha order). Otherwise drops
* non-matches and sorts by score desc, then count desc, then label. */
export function rankTags<T extends { raw: string; label: string; count: number }>(
query: string,
tags: T[],
): T[] {
const q = query.trim();
if (!q) return tags;
return tags
.map((t) => ({ t, s: rankTagMatch(q, t.raw) }))
.filter((x) => x.s >= 0)
.sort((a, b) => b.s - a.s || b.t.count - a.t.count || a.t.label.localeCompare(b.t.label))
.map((x) => x.t);
}

View file

@ -22,16 +22,29 @@ import { getSettings, getTemplatesFormats, onSettingsChange } from "./settings";
import { StashpadSuggest } from "./note-picker";
import { StashpadCommandPalette } from "./command-palette";
import { setActiveView, clearActiveView } from "./active-view";
import { AssignModal, ColorPickerModal, ConfirmDeleteModal, ConfirmModal, DueDatePickerModal, SplitNoteModal } from "./modals";
import { ColorPickerModal, ConfirmDeleteModal, ConfirmModal, DueDatePickerModal, SplitNoteModal } from "./modals";
import { ComposerAutocomplete } from "./composer-autocomplete";
import { matchBinding, humanCombo } from "./view-keys";
import { AuthorshipTracker } from "./authorship-tracker";
import { ViewDnD } from "./view-dnd";
import { NoteBodyRenderer } from "./note-body-renderer";
import { computeSortedIds } from "./view-sort";
import {
SHEET_KEY,
SHEET_ORDER_KEY,
SHEET_FINAL_KEY,
sheetIdOf,
isVersionMember,
sheetIsFinal,
newSheetGroupId,
nodeFm,
sortVersions,
defaultActive,
tabTitle,
} from "./sheets-versions";
import * as clipboardCmds from "./commands/clipboard-cmds";
import * as ioCmds from "./commands/io-cmds";
import { setIconSafe, isAnyModalOpen, properCaseFolderPath, computeReorder, arraysEqual, splitIntoChunks, SPLIT_MODE_LABELS, type SplitMode } from "./view-helpers";
import { setIconSafe, isAnyModalOpen, properCaseFolderPath, computeReorder, arraysEqual, splitIntoChunks, SPLIT_MODE_LABELS, type SplitMode, rankTags, TAG_FILTER_TAGGED, TAG_FILTER_UNTAGGED } from "./view-helpers";
import type StashpadPlugin from "./main";
const IMG_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"]);
@ -251,6 +264,10 @@ export class StashpadView extends ItemView {
* (lazy-loaded) on reload. 0.91.0. */
private lastSelectionByFocus = new Map<StashpadId, StashpadId[]>();
private expandedNotes = new Set<StashpadId>();
/** Sheet versions: which version (note id) of a `sheet:` group is currently
* shown as the row. View-state only (not persisted) falls back to the
* final pick / first-by-order when unset. */
private activeVersionByGroup = new Map<string, StashpadId>();
private focusComposerOnNextRender = false;
/** 0.76.21: timestamp until which the activation auto-focus
* (focusComposer) is suppressed. Set after actions that close a
@ -310,8 +327,8 @@ export class StashpadView extends ItemView {
this.tree.setOrderProvider((parentId) => {
const folder = this.noteFolder;
const mode = this.sortStore.getMode(folder, parentId);
if (mode === "manual") return this.order.getOrder(folder, parentId);
return computeSortedIds(this, parentId, mode);
const base = mode === "manual" ? this.order.getOrder(folder, parentId) : computeSortedIds(this, parentId, mode);
return this.hoistListPinned(parentId, base);
});
this.debouncedRender = debounce(() => { if (this.renderSuppressed()) return; this.render(); }, 80);
this.authorship = new AuthorshipTracker(this);
@ -568,6 +585,8 @@ export class StashpadView extends ItemView {
for (const [focusId, noteId] of loaded) this.lastCursorByFocus.set(focusId, noteId);
// 0.91.0: hydrate the persisted multi-selection alongside the cursor.
this.lastSelectionByFocus = this.plugin.loadLastSelection(this.noteFolder);
// Sheet versions: restore which version of each group is shown.
this.activeVersionByGroup = this.plugin.loadActiveVersions(this.noteFolder);
} catch { /* ignore */ }
// On a fresh mount (app reload, tab restore, first-ever open), scroll
// to the end of the list so the newest notes are visible. Once the
@ -898,6 +917,8 @@ export class StashpadView extends ItemView {
for (const [focusId, noteId] of loaded) this.lastCursorByFocus.set(focusId, noteId);
// 0.91.0: re-hydrate the persisted multi-selection for the new folder.
this.lastSelectionByFocus = this.plugin.loadLastSelection(this.noteFolder);
// Sheet versions: re-hydrate active versions for the new folder.
this.activeVersionByGroup = this.plugin.loadActiveVersions(this.noteFolder);
} catch { /* ignore */ }
const savedCursorId = this.lastCursorByFocus.get(this.focusId);
let policy: ScrollPolicy;
@ -1893,6 +1914,10 @@ export class StashpadView extends ItemView {
}
private filterChildren(children: TreeNode[]): TreeNode[] {
// Sheet versions collapse FIRST and unconditionally (before the
// no-filters early-return below): non-active versions of a group are
// hidden so only the active one shows as a row.
children = this.collapseVersions(children);
const cutoff = this.timeFilterCutoff();
const tag = this.tagFilter?.toLowerCase();
const color = this.colorFilter?.toLowerCase() ?? null;
@ -1918,7 +1943,9 @@ export class StashpadView extends ItemView {
}
if (tag) {
if (!n.file) return false;
if (!this.nodeHasTag(n, tag)) return false;
if (this.tagFilter === TAG_FILTER_TAGGED) { if (!this.nodeHasAnyTag(n)) return false; }
else if (this.tagFilter === TAG_FILTER_UNTAGGED) { if (this.nodeHasAnyTag(n)) return false; }
else if (!this.nodeHasTag(n, tag)) return false;
}
if (color) {
const c = this.colorForNode(n)?.toLowerCase() ?? null;
@ -1936,6 +1963,70 @@ export class StashpadView extends ItemView {
});
}
/** Collapse `sheet:` version groups so only the active version remains in
* the list. Notes without a sheet id pass through untouched. */
private collapseVersions(children: TreeNode[]): TreeNode[] {
// Feature is opt-in: when off, never hide anything.
if (!this.plugin.settings.enableSheetVersions) return children;
// Bucket nodes by group id; non-sheet nodes go straight through.
const groups = new Map<string, TreeNode[]>();
let anyGroup = false;
for (const n of children) {
const fm = nodeFm(this.app, n);
if (!isVersionMember(fm)) continue; // needs BOTH sheet-group + sheet-order
anyGroup = true;
const gid = sheetIdOf(fm)!;
const arr = groups.get(gid);
if (arr) arr.push(n);
else groups.set(gid, [n]);
}
if (!anyGroup) return children;
// For each group, decide the single active member to keep.
const keep = new Set<StashpadId>();
for (const [gid, members] of groups) {
const active = this.activeVersionNode(gid, members);
if (active) keep.add(active.id);
}
return children.filter((n) => {
// Only fully-stamped version members are eligible to be hidden.
if (!isVersionMember(nodeFm(this.app, n))) return true;
return keep.has(n.id);
});
}
/** The version node to show for a group, honoring an explicit user choice
* when that choice is still present in the current set. */
private activeVersionNode(groupId: string, members: TreeNode[]): TreeNode | null {
const chosen = this.activeVersionByGroup.get(groupId);
if (chosen) {
const hit = members.find((m) => m.id === chosen);
if (hit) return hit;
}
return defaultActive(this.app, members);
}
/** Switch which version of a group is shown, persist it, then repaint. */
private setActiveVersion(groupId: string, id: StashpadId): void {
this.activeVersionByGroup.set(groupId, id);
this.plugin.saveActiveVersion(this.noteFolder, groupId, id);
this.render();
}
/** True if `node`'s file carries ANY tag (inline or frontmatter)
* backs the "Tagged"/"Untagged" filter modes. */
private nodeHasAnyTag(node: TreeNode): boolean {
if (!node.file) return false;
const cache = this.app.metadataCache.getFileCache(node.file);
if (!cache) return false;
if (cache.tags && cache.tags.length > 0) return true;
const fmTags = cache.frontmatter?.tags;
if (fmTags) {
const arr = Array.isArray(fmTags) ? fmTags : [fmTags];
if (arr.some((t) => typeof t === "string" && t.trim().length > 0)) return true;
}
return false;
}
/** True if `node`'s file carries `tag` (case-insensitive) checks
* inline tags AND frontmatter `tags`. */
private nodeHasTag(node: TreeNode, tagLower: string): boolean {
@ -2743,6 +2834,10 @@ export class StashpadView extends ItemView {
// group so it's visible without scrolling past the selection commands.
menu.addItem((it: any) => it.setTitle("Reload without saving").setIcon("rotate-ccw").onClick(() => this.plugin.reloadAppForUpdate()));
menu.addSeparator();
// List-wide expand/collapse — operate on every note, independent of selection.
menu.addItem((it: any) => it.setTitle("Expand all").setIcon("unfold-vertical").onClick(() => this.cmdExpandAll()));
menu.addItem((it: any) => it.setTitle("Collapse all").setIcon("fold-vertical").onClick(() => this.cmdCollapseAll()));
menu.addSeparator();
menu.addItem((it: any) => it.setTitle("Open in new Stashpad tab").setIcon("list-tree").setDisabled(!hasTargets).onClick(() => this.cmdOpenInNewStashpadTab()));
menu.addItem((it: any) => it.setTitle("Open in editor").setIcon("pencil").setDisabled(!hasTargets).onClick(() => this.cmdOpenInEditor()));
menu.addSeparator();
@ -2758,6 +2853,10 @@ export class StashpadView extends ItemView {
menu.addItem((it: any) => it.setTitle("Copy").setIcon("copy").setDisabled(!hasTargets).onClick(() => void this.cmdCopy()));
menu.addItem((it: any) => it.setTitle("Copy tree").setIcon("copy-plus").setDisabled(!hasTargets).onClick(() => void this.cmdCopyTree()));
menu.addItem((it: any) => it.setTitle("Clone (duplicate / copy)").setIcon("files").setDisabled(!hasTargets).onClick(() => void this.cmdClone()));
if (this.plugin.settings.enableSheetVersions) {
menu.addItem((it: any) => it.setTitle("Fork as version").setIcon("git-fork").setDisabled(!hasTargets || !exactlyOne).onClick(() => void this.cmdForkVersion()));
menu.addItem((it: any) => it.setTitle("Mark version as final").setIcon("star").setDisabled(!hasTargets || !exactlyOne).onClick(() => void this.cmdMarkVersionFinal()));
}
menu.addItem((it: any) => it.setTitle("Insert template…").setIcon("file-plus-2").onClick(() => this.cmdInsertTemplate()));
menu.addItem((it: any) => it.setTitle("Merge").setIcon("merge").setDisabled(this.selection.size < 2).onClick(() => void this.cmdMerge()));
// Split only operates on a single note — the cmdSplit modal would
@ -2786,27 +2885,131 @@ export class StashpadView extends ItemView {
new StashpadCommandPalette(this.app).open();
}
/** Render the tag-filter <select>. Folder tags are tallied + sorted
* here on each render so newly-added tags appear without a refresh. */
/** Human label for the current tag filter (button text). */
private tagFilterLabel(): string {
if (this.tagFilter === TAG_FILTER_TAGGED) return "Tagged";
if (this.tagFilter === TAG_FILTER_UNTAGGED) return "Untagged";
if (this.tagFilter) return `#${this.formatTagLabel(this.tagFilter)}`;
return this.collectFolderTags().length === 0 ? "No tags" : "All tags";
}
/** 0.104.x: the tag filter is a custom button + searchable popover
* (fused from the iOS/macOS Drafts design) instead of a native
* <select>. Clicking opens a popover with a search box (ranked, X to
* clear), the "Tagged"/"Untagged" special modes, and every folder tag
* navigable by / + Enter. Tags are tallied each render so new ones
* appear without a refresh. */
private renderTagFilterDropdown(bar: HTMLElement): void {
const sel = bar.createEl("select", { cls: "stashpad-tag-filter-select" });
const all = sel.createEl("option", { text: "All tags" });
all.value = "";
if (!this.tagFilter) all.selected = true;
const tags = this.collectFolderTags();
if (tags.length === 0) {
sel.disabled = true;
all.text = "No tags";
} else {
for (const t of tags) {
const opt = sel.createEl("option", { text: `${t.label} (${t.count})` });
opt.value = t.raw;
if (this.tagFilter && this.tagFilter.toLowerCase() === t.raw.toLowerCase()) opt.selected = true;
}
}
const btn = bar.createDiv({ cls: "stashpad-tag-filter-btn" });
btn.setAttribute("role", "button");
btn.setAttribute("tabindex", "0");
const icon = btn.createSpan({ cls: "stashpad-tag-filter-btn-icon" });
setIconSafe(icon, "tag", "#");
btn.createSpan({ cls: "stashpad-tag-filter-label", text: this.tagFilterLabel() });
if (tags.length === 0 && !this.tagFilter) btn.addClass("is-disabled");
const open = (e: Event): void => {
e.preventDefault();
// Allow opening with a stale filter active even if no tags remain, so
// it's always recoverable via the "All tags" reset.
if (tags.length === 0 && !this.tagFilter) return;
this.openTagFilterMenu(btn);
};
btn.onclick = open;
btn.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") open(e); };
}
sel.onchange = () => this.setTagFilter(sel.value || null);
/** Searchable tag-filter popover anchored beneath `anchor`. Mirrors the
* color-filter popover's outside-click + Escape teardown. */
private openTagFilterMenu(anchor: HTMLElement): void {
const doc = anchor.ownerDocument ?? document;
doc.querySelectorAll(".stashpad-tag-filter-popover").forEach((el) => el.remove());
const pop = doc.body.createDiv({ cls: "stashpad-tag-filter-popover" });
const r = anchor.getBoundingClientRect();
pop.setCssStyles({
left: `${Math.max(8, r.left)}px`,
top: `${r.bottom + 4}px`,
minWidth: `${Math.max(r.width, 200)}px`,
maxWidth: "min(320px, calc(100vw - 16px))",
width: "max-content",
});
const scope = new Scope((this.app as any).scope);
const close = (): void => {
pop.remove();
doc.removeEventListener("mousedown", outside, true);
try { (this.app as any).keymap?.popScope(scope); } catch { /* ignore */ }
};
const outside = (ev: MouseEvent): void => {
if (!pop.contains(ev.target as Node) && ev.target !== anchor && !anchor.contains(ev.target as Node)) close();
};
// Search row: icon + input + (conditionally) an X-to-clear button.
const searchRow = pop.createDiv({ cls: "stashpad-tag-filter-search" });
const sIcon = searchRow.createSpan({ cls: "stashpad-tag-filter-search-icon" });
setIconSafe(sIcon, "search", "⌕");
const input = searchRow.createEl("input", { type: "text", cls: "stashpad-tag-filter-input", attr: { placeholder: "Filter tags…" } });
const clearX = searchRow.createSpan({ cls: "stashpad-tag-filter-clear", attr: { "aria-label": "Clear" } });
setIconSafe(clearX, "x", "×");
clearX.setCssStyles({ display: "none" });
const list = pop.createDiv({ cls: "stashpad-tag-filter-list" });
const allTags = this.collectFolderTags();
let rows: Array<{ value: string | null; el: HTMLElement }> = [];
let highlight = 0;
const select = (value: string | null): void => { this.setTagFilter(value); close(); };
const setHighlight = (i: number): void => {
if (rows.length === 0) { highlight = 0; return; }
highlight = (i + rows.length) % rows.length;
rows.forEach((row, k) => row.el.toggleClass("is-highlighted", k === highlight));
rows[highlight].el.scrollIntoView({ block: "nearest" });
};
const addRow = (label: string, value: string | null, count: number | undefined, active: boolean): void => {
const row = list.createDiv({ cls: "stashpad-tag-filter-row" });
if (active) row.addClass("is-active");
row.createSpan({ cls: "stashpad-tag-filter-row-label", text: label });
if (typeof count === "number") row.createSpan({ cls: "stashpad-tag-filter-row-count", text: String(count) });
const idx = rows.length;
rows.push({ value, el: row });
row.onmousedown = (e) => { e.preventDefault(); select(value); };
row.onmouseenter = () => setHighlight(idx);
};
const renderList = (): void => {
const q = input.value;
clearX.setCssStyles({ display: q ? "" : "none" });
list.empty();
rows = [];
const activeRaw = this.tagFilter?.toLowerCase();
if (!q.trim()) {
// Empty query: special modes pinned on top, then every tag.
addRow("All tags", null, undefined, !this.tagFilter);
addRow("Tagged", TAG_FILTER_TAGGED, undefined, this.tagFilter === TAG_FILTER_TAGGED);
addRow("Untagged", TAG_FILTER_UNTAGGED, undefined, this.tagFilter === TAG_FILTER_UNTAGGED);
for (const t of allTags) addRow(`#${t.label}`, t.raw, t.count, activeRaw === t.raw.toLowerCase());
} else {
// Typing: hide the specials so ↑/↓ jump straight to ranked tags.
const ranked = rankTags(q, allTags);
if (ranked.length === 0) list.createDiv({ cls: "stashpad-tag-filter-empty", text: "No matching tags" });
else for (const t of ranked) addRow(`#${t.label}`, t.raw, t.count, activeRaw === t.raw.toLowerCase());
}
setHighlight(0);
};
clearX.onclick = () => { input.value = ""; renderList(); input.focus(); };
input.addEventListener("input", renderList);
input.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown") { e.preventDefault(); setHighlight(highlight + 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight(highlight - 1); }
else if (e.key === "Enter") { e.preventDefault(); if (rows[highlight]) select(rows[highlight].value); }
});
scope.register([], "Escape", (ev: KeyboardEvent) => { ev.preventDefault(); close(); return false; });
(this.app as any).keymap?.pushScope(scope);
renderList();
setTimeout(() => { doc.addEventListener("mousedown", outside, true); input.focus(); }, 0);
}
/** Color filter custom button + popover. Native <select> is unable
@ -3524,6 +3727,8 @@ export class StashpadView extends ItemView {
};
};
addRow(tags.length === 0 ? "No tags" : "All tags", null);
addRow("Tagged", TAG_FILTER_TAGGED);
addRow("Untagged", TAG_FILTER_UNTAGGED);
for (const t of tags) addRow(`${t.label} (${t.count})`, t.raw);
}
@ -4250,6 +4455,7 @@ export class StashpadView extends ItemView {
if (isCursor && this.plugin.settings.autoExpandCursorRow) row.addClass("is-cursor-expanded");
if (isPickTarget) row.addClass("is-pick-target");
if (this.isCompleted(node)) row.addClass("is-completed");
if (this.isListPinned(node.id)) row.addClass("is-list-pinned");
// 0.99.5: ghost rows that are sitting on a pending CUT (note clipboard),
// mirroring a file manager — they're about to move/be-extracted on paste.
if (this.isCutPending(node.id)) row.addClass("is-cut-pending");
@ -4314,8 +4520,16 @@ export class StashpadView extends ItemView {
// horizontal line below the timestamp — the mobile checkbox sits just to the
// LEFT of the arrow (see the desktop addTaskCheckbox call above).
const mobileTask = showCheckbox && Platform.isMobile;
if (childCount > 0 || mobileTask) {
const isPinnedRow = this.isListPinned(node.id);
if (childCount > 0 || mobileTask || isPinnedRow) {
const metaBottom = meta.createDiv({ cls: "stashpad-note-meta-bottom" });
// 0.106.x: list-pin indicator — a lucide pin icon under the timestamp,
// placed before the children-count arrow.
if (isPinnedRow) {
const pin = metaBottom.createSpan({ cls: "stashpad-note-listpin" });
setIcon(pin, "pin");
pin.setAttr("aria-label", "Pinned to top of list");
}
if (mobileTask) this.addTaskCheckbox(metaBottom, node);
if (childCount > 0) {
const enter = metaBottom.createSpan({ cls: "stashpad-note-enter" });
@ -4387,9 +4601,54 @@ export class StashpadView extends ItemView {
// Show More toggle into that cluster (anchored before the first button).
this.renderNoteBody(bodyContent, node, { clamp: true, toggleHost: actions, toggleAnchor });
// Sheet version tabs (only when this row is part of a multi-version group).
this.renderVersionTabs(body, node);
row.oncontextmenu = (evt) => { evt.preventDefault(); this.openNoteMenu(evt, node); };
}
/** Render the version tab bar at the bottom of a row whose note belongs to a
* `sheet:` group with more than one version. */
private renderVersionTabs(body: HTMLElement, node: TreeNode): void {
if (!this.plugin.settings.enableSheetVersions) return;
if (!isVersionMember(nodeFm(this.app, node))) return;
const gid = sheetIdOf(nodeFm(this.app, node))!;
const parent = node.parent ?? this.focusId;
const siblings = this.tree.getChildren(parent);
const members = sortVersions(
this.app,
siblings.filter((s) => {
const fm = nodeFm(this.app, s);
return isVersionMember(fm) && sheetIdOf(fm) === gid;
}),
);
if (members.length < 2) return;
const bar = body.createDiv({ cls: "stashpad-version-tabs" });
for (const m of members) {
const isActive = m.id === node.id;
const isFinal = sheetIsFinal(nodeFm(this.app, m));
const tab = bar.createDiv({
cls:
"stashpad-version-tab" +
(isActive ? " is-active" : "") +
(isFinal ? " is-final" : ""),
});
if (isFinal) tab.createSpan({ cls: "stashpad-version-star", text: "★" });
tab.createSpan({
cls: "stashpad-version-tab-label",
text: tabTitle(this.app, m, this.titleForNode(m).trim() || "Untitled"),
});
tab.title = this.titleForNode(m).trim();
if (!isActive) {
tab.onclick = (e) => { e.stopPropagation(); this.setActiveVersion(gid, m.id); };
}
}
const add = bar.createEl("button", { cls: "stashpad-version-add", text: "+" });
add.title = "New version";
add.onclick = (e) => { e.stopPropagation(); void this.cmdForkVersion(node, true); };
}
/** Create + wire the task checkbox (used at the row's left edge on desktop,
* or inside the meta column on mobile). 0.87.1. */
private addTaskCheckbox(parent: HTMLElement, node: TreeNode): void {
@ -4455,7 +4714,7 @@ export class StashpadView extends ItemView {
// appending fresh nodes.
container.empty();
const textEl = container.createDiv({ cls: "stashpad-note-text" });
const expanded = this.expandedNotes.has(node.id);
const expanded = this.isNoteExpanded(node.id);
if (opts.clamp && !expanded) textEl.addClass("is-clamped");
// 0.71.23: in compact/tiny modes the row is too short to host
// rendered markdown — headings overflow, code blocks get clipped
@ -4557,8 +4816,7 @@ export class StashpadView extends ItemView {
}
toggle.onclick = (e) => {
e.stopPropagation();
if (this.expandedNotes.has(node.id)) this.expandedNotes.delete(node.id);
else this.expandedNotes.add(node.id);
this.setNoteExpanded(node.id, !this.isNoteExpanded(node.id));
// Re-render just this body in place to preserve list scroll.
container.empty();
this.renderNoteBody(container, node, opts);
@ -5524,9 +5782,13 @@ export class StashpadView extends ItemView {
if (matchBinding(e, sb.openTab)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdOpenInNewStashpadTab(); return; }
if (matchBinding(e, sb.split)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); void this.cmdSplit(); return; }
if (matchBinding(e, sb.clone)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); void this.cmdClone(); return; }
if (matchBinding(e, sb.forkNote)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdForkNote(); return; }
if (matchBinding(e, sb.insertTemplate)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdInsertTemplate(); return; }
if (matchBinding(e, sb.toggleExpand)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdToggleExpand(); return; }
if (matchBinding(e, sb.expandAll)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdExpandAll(); return; }
if (matchBinding(e, sb.collapseAll)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdCollapseAll(); return; }
if (matchBinding(e, sb.togglePin)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); void this.cmdTogglePin(); return; }
if (matchBinding(e, sb.listPin)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); void this.cmdToggleListPin(); return; }
if (matchBinding(e, sb.toggleTask)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); void this.cmdToggleTask(); return; }
if (matchBinding(e, sb.setDue)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdSetDue(); return; }
}
@ -6855,11 +7117,37 @@ export class StashpadView extends ItemView {
cmdToggleExpand(): void {
const targets = this.getActionTargets();
if (!targets.length) return;
const anyCollapsed = targets.some((t) => !this.expandedNotes.has(t.id));
for (const t of targets) {
if (anyCollapsed) this.expandedNotes.add(t.id);
else this.expandedNotes.delete(t.id);
}
const anyCollapsed = targets.some((t) => !this.isNoteExpanded(t.id));
for (const t of targets) this.setNoteExpanded(t.id, anyCollapsed);
this.render();
}
/** Is this note's body currently shown expanded (un-clamped)?
* The expandedNotes Set stores ids that DIFFER from the default:
* when expandBodiesByDefault is off (default), membership = expanded;
* when on, membership = collapsed. This lets one Set serve both modes. */
isNoteExpanded(id: StashpadId): boolean {
const differsFromDefault = this.expandedNotes.has(id);
return this.plugin.settings.expandBodiesByDefault ? !differsFromDefault : differsFromDefault;
}
/** Set a note's expanded/collapsed state, normalizing against the
* default so the Set only ever holds the non-default ids. */
setNoteExpanded(id: StashpadId, expanded: boolean): void {
const defaultExpanded = this.plugin.settings.expandBodiesByDefault;
if (expanded === defaultExpanded) this.expandedNotes.delete(id);
else this.expandedNotes.add(id);
}
/** Expand every note in the current list (un-clamp all bodies). */
cmdExpandAll(): void {
for (const n of this.currentChildren) this.setNoteExpanded(n.id, true);
this.render();
}
/** Collapse every note in the current list (re-clamp all bodies). */
cmdCollapseAll(): void {
for (const n of this.currentChildren) this.setNoteExpanded(n.id, false);
this.render();
}
@ -7023,6 +7311,187 @@ export class StashpadView extends ItemView {
});
}
/** Fork a single note: duplicate its subtree as a standalone "variant" and
* place it under a parent chosen via the picker. The current parent is
* named as the default in the placeholder; Esc cancels. Same-folder only
* for now (cross-folder forking deferred). NOTE: distinct from the sheet
* "Fork as version" (cmdForkVersion) that makes a draft within a sheet
* group; this makes a separate note you can re-home. */
cmdForkNote(): void {
const node = this.getActionTargets()[0];
if (!node?.file) { new Notice("Nothing to fork."); return; }
const curParent = node.parent ?? ROOT_ID;
const curNode = curParent === ROOT_ID ? null : this.tree.get(curParent);
const curLabel = curNode ? this.titleForNode(curNode) : "Home";
new StashpadSuggest(this.app, this.tree, (n) => this.titleForNode(n), {
mode: "pick",
allowCreate: true,
placeholder: `Fork "${this.titleForNode(node)}" under… (default: ${curLabel})`,
onPick: (item) => {
if (item.crossFolder) { new Notice("Fork stays in this folder for now."); return; }
void this.forkNoteUnder(node, item.id);
},
onCreate: (q) => { void (async () => { const id = await this.createNoteUnder(q, this.focusId); if (id) await this.forkNoteUnder(node, id); })(); },
}).open();
}
/** Clone `node`'s subtree under `parentId`, focus the new root, with undo.
* Mirrors cmdClone's clone+snapshot+undo machinery for a single node. */
private async forkNoteUnder(node: TreeNode, parentId: StashpadId): Promise<void> {
if (!node.file) return;
const folder = this.noteFolder;
const createdPaths: string[] = [];
const newRootId = await this.cloneSubtree(node, parentId, createdPaths);
if (!newRootId) return;
this.tree.rebuild(folder);
this.pendingFocusIds = [newRootId];
this.render();
const snapNodes: TreeNode[] = createdPaths
.map((p) => this.app.vault.getAbstractFileByPath(p))
.filter((f): f is TFile => !!f && (f as any).extension === "md")
.map((file) => ({ id: parseIdFromFilename(file.basename) ?? file.basename, parent: null, children: [], file, created: new Date().toISOString() }));
const snap = await this.snapshotNotes(snapNodes, false);
this.plugin.getUndoStack(folder).push({
label: "Fork note",
undo: async () => {
for (const p of [...createdPaths].reverse()) {
const f = this.app.vault.getAbstractFileByPath(p) as TFile | null;
if (f) { try { await this.app.fileManager.trashFile(f); } catch { /* ignore */ } }
}
this.tree.rebuild(folder);
this.render();
},
redo: async () => { await this.restoreSnapshots(snap, [newRootId]); },
});
const forked = this.tree.get(newRootId);
this.plugin.notifications.show({
message: forked
? `Forked "${this.titleForNode(node)}" → "${this.titleForNode(forked)}" (${createdPaths.length} file${createdPaths.length === 1 ? "" : "s"})`
: "Forked note",
kind: "success",
category: "clone",
affectedIds: [newRootId],
folder,
});
}
// --- Sheet versions ---
/** Ensure a note carries a `sheet:` group id, stamping a fresh one (and an
* initial order of 0) if it doesn't. Returns the group id. */
private async ensureSheetGroup(file: TFile): Promise<string> {
const existing = sheetIdOf(this.app.metadataCache.getFileCache(file)?.frontmatter);
if (existing) return existing;
const gid = newSheetGroupId();
await this.app.fileManager.processFrontMatter(file, (m: any) => {
m[SHEET_KEY] = gid;
if (typeof m[SHEET_ORDER_KEY] !== "number") m[SHEET_ORDER_KEY] = 0;
});
return gid;
}
/** Create a new version of `source` as a sibling sharing its sheet group.
* `blank` empty body; otherwise the source body is duplicated (a fork).
* The new version becomes the shown one. Used by the row "+" button, the
* context menu, and the command. */
async cmdForkVersion(source?: TreeNode, blank = false): Promise<void> {
if (!this.plugin.settings.enableSheetVersions) {
new Notice("Sheet versions are off — enable them in Settings → General.");
return;
}
const src = source ?? this.getActionTargets()[0];
if (!src?.file) { new Notice("Select a note to version."); return; }
const folder = this.noteFolder;
const gid = await this.ensureSheetGroup(src.file);
const parent = src.parent ?? this.focusId;
// Next order = one past the current highest in the group.
const siblings = this.tree.getChildren(parent)
.filter((s) => sheetIdOf(nodeFm(this.app, s)) === gid);
let maxOrder = -1;
for (const s of siblings) {
const o = this.app.metadataCache.getFileCache(s.file!)?.frontmatter?.[SHEET_ORDER_KEY];
if (typeof o === "number" && o > maxOrder) maxOrder = o;
}
const order = maxOrder + 1;
const body = blank ? "" : this.stripFrontmatter(await this.app.vault.read(src.file));
const cloneId = newId();
const slug = bodyToSlug(body, this.activeStopwords());
const filename = buildFilename(slug, cloneId);
const path = `${folder}/${filename}`;
const created = new Date().toISOString();
const attachments = this.extractAttachments(body);
const fmInit = ["---", `id: ${cloneId}`, `parent: ${parent}`, `created: ${created}`];
if (attachments.length > 0) {
fmInit.push("attachments:");
for (const a of attachments) fmInit.push(` - "${a.replace(/"/g, '\\"')}"`);
} else {
fmInit.push("attachments: []");
}
fmInit.push("---", body);
await this.ensureFolder(folder);
await this.app.vault.create(path, fmInit.join("\n"));
const newFile = this.app.vault.getAbstractFileByPath(path) as TFile | null;
if (!newFile) { new Notice("Sheets: could not create version."); return; }
await this.app.fileManager.processFrontMatter(newFile, (m: any) => {
m[SHEET_KEY] = gid;
m[SHEET_ORDER_KEY] = order;
delete m[SHEET_FINAL_KEY];
});
try {
this.tree.insertSynthetic({ id: cloneId, parent, children: [], file: newFile, created });
} catch { /* ignore */ }
this.fmSync.scheduleParentChange(cloneId, null, parent);
this.tree.rebuild(folder);
this.activeVersionByGroup.set(gid, cloneId); // show the new version
this.plugin.saveActiveVersion(folder, gid, cloneId);
this.render();
this.plugin.getUndoStack(folder).push({
label: blank ? "New version" : "Fork version",
undo: async () => {
const f = this.app.vault.getAbstractFileByPath(path) as TFile | null;
if (f) { try { await this.app.fileManager.trashFile(f); } catch { /* ignore */ } }
this.activeVersionByGroup.delete(gid);
this.tree.rebuild(folder);
this.render();
},
redo: async () => { /* best-effort: re-running the command recreates it */ },
});
new Notice(blank ? "New version added" : "Forked a new version");
}
/** Toggle the "final" flag on a version, clearing it from its group-mates. */
async cmdMarkVersionFinal(target?: TreeNode): Promise<void> {
if (!this.plugin.settings.enableSheetVersions) {
new Notice("Sheet versions are off — enable them in Settings → General.");
return;
}
const node = target ?? this.getActionTargets()[0];
if (!node?.file) return;
const gid = sheetIdOf(nodeFm(this.app, node));
if (!gid) { new Notice("Not a versioned note."); return; }
const makeFinal = !sheetIsFinal(nodeFm(this.app, node));
const parent = node.parent ?? this.focusId;
const members = this.tree.getChildren(parent)
.filter((s) => sheetIdOf(nodeFm(this.app, s)) === gid);
for (const m of members) {
if (!m.file) continue;
const wasFinal = sheetIsFinal(nodeFm(this.app, m));
const shouldBeFinal = makeFinal && m.id === node.id;
if (wasFinal === shouldBeFinal) continue;
await this.app.fileManager.processFrontMatter(m.file, (fm: any) => {
if (shouldBeFinal) fm[SHEET_FINAL_KEY] = true;
else delete fm[SHEET_FINAL_KEY];
});
}
this.render();
}
// ---- 0.99.0: note clipboard — copy / cut / paste of note BLOCKS ----
// Runs in parallel with the system clipboard: copy/cut also put the bodies
// on the system clipboard as text (so pasting in the composer or any app
@ -7372,7 +7841,9 @@ export class StashpadView extends ItemView {
// Clear an active tag/color filter if the new subtree doesn't
// contain it — otherwise we'd show "All …" in the dropdown while
// a hidden filter empties the list.
if (this.tagFilter) {
if (this.tagFilter && this.tagFilter !== TAG_FILTER_TAGGED && this.tagFilter !== TAG_FILTER_UNTAGGED) {
// Sentinel modes (Tagged/Untagged) are always valid; only real tags
// get cleared when the new subtree doesn't contain them.
const wanted = this.tagFilter.toLowerCase();
const present = this.collectFolderTags().some((t) => t.raw.toLowerCase() === wanted);
if (!present) this.tagFilter = null;
@ -7909,6 +8380,92 @@ export class StashpadView extends ItemView {
else if (unpinned > 0) new Notice(`Unpinned ${unpinned} note${unpinned === 1 ? "" : "s"} from sidebar.`);
}
/** Is this note list-pinned (floated to the top of its sibling list)?
* Distinct from the sidebar pin (plugin.isPinned). Reads frontmatter. */
isListPinned(id: StashpadId): boolean {
const node = this.tree.get(id);
if (!node?.file) return false;
const ov = this.listPinnedState.get(node.file.path);
if (ov) return ov.pinned;
return this.app.metadataCache.getFileCache(node.file)?.frontmatter?.listPinned === true;
}
private listPinnedAt(id: StashpadId): number {
const node = this.tree.get(id);
if (!node?.file) return 0;
const ov = this.listPinnedState.get(node.file.path);
if (ov) return ov.at;
const v = this.app.metadataCache.getFileCache(node.file)?.frontmatter?.listPinnedAt;
const t = typeof v === "string" ? Date.parse(v) : NaN;
return Number.isNaN(t) ? 0 : t;
}
/** Float list-pinned children to the top of `base` (in pin order). Returns
* `base` unchanged when nothing in this parent is pinned so unpinned
* lists keep EXACTLY their prior ordering/behavior (zero regression risk). */
private hoistListPinned(parentId: StashpadId, base: StashpadId[]): StashpadId[] {
const childIds = this.tree.getChildren(parentId).map((n) => n.id);
if (!childIds.some((id) => this.isListPinned(id))) return base;
// base may be empty (manual order unset) — fall back to the tree's order.
const seen = new Set(base);
const full = [...base, ...childIds.filter((id) => !seen.has(id))];
const pinned = full.filter((id) => this.isListPinned(id)).sort((a, b) => this.listPinnedAt(a) - this.listPinnedAt(b));
const rest = full.filter((id) => !this.isListPinned(id));
return [...pinned, ...rest];
}
/** Toggle the list pin (float-to-top) on the action targets. Distinct from
* the sidebar pin (cmdTogglePin). Writes listPinned/listPinnedAt + undo. */
async cmdToggleListPin(): Promise<void> {
let targets = this.getActionTargets();
if (targets.length === 0) {
const focused = this.tree.get(this.focusId);
if (focused?.file) targets = [focused];
}
if (targets.length === 0) { new Notice("Nothing to pin."); return; }
const anyUnpinned = targets.some((t) => !this.isListPinned(t.id));
const prior: { path: string; listPinned: unknown; listPinnedAt: unknown }[] = [];
const stamp = new Date().toISOString();
const changed: StashpadId[] = [];
for (const t of targets) {
if (!t.file) continue;
const fm = this.app.metadataCache.getFileCache(t.file)?.frontmatter as any;
prior.push({ path: t.file.path, listPinned: fm?.listPinned, listPinnedAt: fm?.listPinnedAt });
const at = anyUnpinned ? (typeof fm?.listPinnedAt === "string" ? Date.parse(fm.listPinnedAt) || Date.parse(stamp) : Date.parse(stamp)) : 0;
// Override now so the re-sort below reflects the new state immediately
// (the metadata cache lags the frontmatter write by a tick).
this.listPinnedState.set(t.file.path, { pinned: anyUnpinned, at });
await this.app.fileManager.processFrontMatter(t.file, (m) => {
if (anyUnpinned) { m.listPinned = true; if (!m.listPinnedAt) m.listPinnedAt = stamp; }
else { delete m.listPinned; delete m.listPinnedAt; }
});
changed.push(t.id);
}
const folder = this.noteFolder;
this.tree.rebuild(folder);
this.render();
this.plugin.notifications.show({
message: anyUnpinned ? `Pinned ${changed.length} to top of list` : `Unpinned ${changed.length} from list`,
kind: "success", category: "edit", affectedIds: changed, folder,
});
this.plugin.getUndoStack(folder).push({
label: anyUnpinned ? `Pin to top (${changed.length})` : `Unpin from top (${changed.length})`,
undo: async () => {
for (const p of prior) {
const f = this.app.vault.getAbstractFileByPath(p.path) as TFile | null;
if (!f) continue;
await this.app.fileManager.processFrontMatter(f, (m) => {
if (p.listPinned === undefined) delete m.listPinned; else m.listPinned = p.listPinned;
if (p.listPinnedAt === undefined) delete m.listPinnedAt; else m.listPinnedAt = p.listPinnedAt;
});
const wasPinned = p.listPinned === true;
const at = typeof p.listPinnedAt === "string" ? (Date.parse(p.listPinnedAt) || 0) : 0;
this.listPinnedState.set(p.path, { pinned: wasPinned, at });
}
this.tree.rebuild(folder);
this.render();
},
});
}
async cmdToggleComplete(): Promise<void> {
let targets = this.getActionTargets();
if (targets.length === 0) {
@ -8032,6 +8589,12 @@ export class StashpadView extends ItemView {
* back to the live cache when there's no override. */
private taskTaggedState = new Map<string, boolean>();
/** 0.105.0: list-pin override per path (pinned flag + pin timestamp), so a
* pin/unpin re-sorts the list on THIS render rather than n+1 (the metadata
* cache lags a frontmatter write). Reads fall back to the live cache when
* there's no override. */
private listPinnedState = new Map<string, { pinned: boolean; at: number }>();
private isCompleted(node: TreeNode): boolean {
if (!node.file) return false;
const override = this.completedState.get(node.file.path);
@ -8146,9 +8709,12 @@ export class StashpadView extends ItemView {
});
}
/** 0.78.3: standalone assign command assign people to the target
* task(s) without touching the due date. Opens AssignModal pre-filled
* with the first target's current assignees. */
/** 0.104.x: "Assign to" opens the SAME unified modal as "Set due date"
* (date+time picker + assignee picker). Both commands now share one
* modal only the title differs. Pre-fills the first target's current
* due date AND assignees, and commits through applyDue (which flips
* `task: true` on any due/assignment so assigning or scheduling a
* plain note auto-converts it to a task). */
cmdAssign(): void {
let targets = this.getActionTargets();
if (targets.length === 0) {
@ -8158,76 +8724,12 @@ export class StashpadView extends ItemView {
if (targets.length === 0) { new Notice("Nothing to assign."); return; }
const first = targets[0];
const curFm = first.file ? this.app.metadataCache.getFileCache(first.file)?.frontmatter as any : null;
const current = curFm && (typeof curFm.due === "string" || typeof curFm.due === "number") ? String(curFm.due) : null;
const knownAuthors = this.plugin.collectKnownAuthors();
const currentAssignees = parseAssignees(curFm ?? {});
new AssignModal(this.app, { knownAuthors, currentAssignees }, (assignees) => {
void this.applyAssignees(targets, assignees);
}).open();
}
/** Write `assignedTo`/`assignedBy` across `targets` (without touching
* `due`), with undo. An empty list clears the assignment. Assigning
* also flips `task: true`. */
private async applyAssignees(targets: TreeNode[], assignees: Array<{ id: string; name: string }>): Promise<void> {
const me = this.authorship.currentAuthorLink();
for (const a of assignees) {
await this.plugin.ensureAuthorStubFor(this.noteFolder, a.id, a.name);
}
const assignLinks = assignees.map((a) => this.plugin.authorRefFor(this.noteFolder, a.id, a.name));
const prior: { path: string; assignedTo: unknown; assignedBy: unknown; task: unknown; wasTagged: boolean }[] = [];
const changedIds: StashpadId[] = [];
for (const t of targets) {
if (!t.file) continue;
const fm = this.app.metadataCache.getFileCache(t.file)?.frontmatter as any;
const wasTagged = this.isTaskTagged(t);
prior.push({ path: t.file.path, assignedTo: fm?.assignedTo, assignedBy: fm?.assignedBy, task: fm?.task, wasTagged });
await this.app.fileManager.processFrontMatter(t.file, (m) => {
if (assignLinks.length > 0) {
m.assignedTo = assignLinks;
if (me) m.assignedBy = me.link;
m.task = true;
} else {
delete m.assignedTo;
delete m.assignedBy;
}
});
// 0.85.1: assigning makes it a task; clearing leaves task-ness unchanged.
this.taskTaggedState.set(t.file.path, assignLinks.length > 0 || wasTagged);
changedIds.push(t.id);
}
this.render();
if (changedIds.length > 0) {
const nodes = changedIds.map((id) => this.tree.get(id)).filter((n): n is TreeNode => !!n);
const names = assignees.map((a) => a.name).join(", ");
this.plugin.notifications.show({
message: this.bulkActionMessage({
verb: assignLinks.length > 0 ? `Assigned to ${names}` : "Cleared assignment",
nodes,
}),
kind: "success",
category: "edit",
affectedIds: changedIds,
folder: this.noteFolder,
});
}
const folder = this.noteFolder;
this.plugin.getUndoStack(folder).push({
label: assignLinks.length > 0 ? `Assign (${targets.length})` : `Clear assignment (${targets.length})`,
undo: async () => {
for (const p of prior) {
const f = this.app.vault.getAbstractFileByPath(p.path) as TFile | null;
if (!f) continue;
await this.app.fileManager.processFrontMatter(f, (m) => {
if (p.assignedTo === undefined) delete m.assignedTo; else m.assignedTo = p.assignedTo;
if (p.assignedBy === undefined) delete m.assignedBy; else m.assignedBy = p.assignedBy;
if (p.task === undefined) delete m.task; else m.task = p.task;
});
this.taskTaggedState.set(p.path, p.wasTagged); // 0.85.1
}
this.tree.rebuild(folder);
this.render();
},
});
new DueDatePickerModal(this.app, current, (result) => {
void this.applyDue(targets, result.iso, result.assignees);
}, { knownAuthors, currentAssignees, title: "Assign / schedule task" }).open();
}
/** 0.76.3: a note is a task when it carries the `task` tag in
@ -9943,6 +10445,10 @@ 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();
}));
menu.addItem((it: any) => it.setTitle("Fork (copy under another parent)…").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()));
menu.addItem((it: any) => it.setTitle("Export to .stash").setIcon("package").onClick(() => {
// Multi-select normalisation (matches Clone / Delete / Set color):
@ -9991,19 +10497,52 @@ export class StashpadView extends ItemView {
if (pinned) await this.plugin.unpinNote(pinRef);
else await this.plugin.pinNote(pinRef);
}));
// 0.105.0: list pin — float to the top of THIS list (distinct from sidebar).
const listPinned = this.isListPinned(node.id);
menu.addItem((it: any) => it
.setTitle(listPinned ? "Unpin from top of list" : "Pin to top of list")
.setIcon(listPinned ? "pin-off" : "arrow-up-to-line")
.onClick(() => {
if (!this.selection.has(node.id)) { this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id; }
void this.cmdToggleListPin();
}));
menu.addItem((it: any) => it.setTitle("Set color…").setIcon("palette").onClick(() => {
// Operate on the right-clicked row even if it isn't selected.
if (!this.selection.has(node.id)) { this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id; }
this.cmdSetColor();
}));
// 0.58.0: toggle complete — label flips based on current state of the
// right-clicked node. Operates on the right-clicked row, normalising
// selection first so cmdToggleComplete picks the right target.
const isDone = this.isCompleted(node);
menu.addItem((it: any) => it.setTitle(isDone ? "Mark incomplete" : "Mark complete").setIcon(isDone ? "circle" : "check-circle").onClick(() => {
// 0.104.x: task actions grouped under a "Task ▸" submenu to keep the
// right-click menu compact (Obsidian already repositions to stay
// on-screen; height is the real lever). Task gating: completion is only
// offered once a note IS a task — non-tasks show "Turn into task"; tasks
// show "Mark complete" + "Remove from tasks". Right-click is single-node,
// so the gate is unambiguous (the mobile ⚡ menu keeps its multi-select
// toggles). Every entry normalises selection to the right-clicked row.
// setSubmenu is internal/untyped — accessed via the existing `it: any`
// pattern; falls back to opening the command palette if unavailable
// (effectively never on the 1.13 minAppVersion floor).
const focusClicked = (): void => {
if (!this.selection.has(node.id)) { this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id; }
void this.cmdToggleComplete();
}));
};
const addTaskItems = (target: { addItem: (cb: (it: any) => unknown) => unknown }): void => {
const isTaskNote = this.isTask(node);
if (isTaskNote) {
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 {
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(); }));
if (isTaskNote) {
target.addItem((it: any) => it.setTitle("Remove from tasks").setIcon("square").onClick(() => { focusClicked(); void this.cmdToggleTask(); }));
}
};
menu.addItem((it: any) => {
it.setTitle("Task").setIcon("check-square");
const sub = typeof it.setSubmenu === "function" ? it.setSubmenu() : null;
if (sub && typeof sub.addItem === "function") addTaskItems(sub);
else it.onClick(() => this.openCommandPalette()); // degraded fallback
});
menu.addSeparator();
menu.addItem((it: any) => it.setTitle("Delete").setIcon("trash").onClick(async () => {
// Route through cmdDelete (not deleteNote directly) so the encryptTrash

View file

@ -704,6 +704,109 @@
.stashpad-color-filter-popover-label {
white-space: nowrap;
}
/* --- Tag filter: custom button + searchable popover (0.104.x) --- */
.stashpad-tag-filter-btn {
flex: 0 0 auto;
width: max-content;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 6px;
background: var(--background-secondary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
font-size: var(--font-ui-smaller);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.stashpad-tag-filter-btn:hover { background: var(--background-modifier-hover); }
.stashpad-tag-filter-btn.is-disabled { opacity: 0.5; cursor: not-allowed; }
.stashpad-tag-filter-btn-icon {
display: inline-flex;
align-items: center;
color: var(--text-muted);
}
.stashpad-tag-filter-btn-icon .svg-icon { width: 14px; height: 14px; }
.stashpad-tag-filter-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 16ch;
}
.stashpad-view.is-mobile .stashpad-tag-filter-btn { height: 28px; }
.stashpad-tag-filter-popover {
position: fixed;
z-index: var(--layer-popover, 30);
display: flex;
flex-direction: column;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
font-size: var(--font-ui-small);
max-height: 340px;
overflow: hidden;
}
.stashpad-tag-filter-search {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-bottom: 1px solid var(--background-modifier-border);
}
.stashpad-tag-filter-search-icon {
display: inline-flex;
align-items: center;
color: var(--text-muted);
}
.stashpad-tag-filter-search-icon .svg-icon { width: 14px; height: 14px; }
.stashpad-tag-filter-input {
flex: 1 1 auto;
min-width: 0;
border: none;
background: transparent;
color: var(--text-normal);
font-size: var(--font-ui-small);
outline: none;
padding: 2px 0;
}
.stashpad-tag-filter-clear {
display: inline-flex;
align-items: center;
color: var(--text-muted);
cursor: pointer;
border-radius: 4px;
padding: 2px;
}
.stashpad-tag-filter-clear:hover { color: var(--text-normal); background: var(--background-modifier-hover); }
.stashpad-tag-filter-clear .svg-icon { width: 13px; height: 13px; }
.stashpad-tag-filter-list {
overflow-y: auto;
padding: 4px 0;
}
.stashpad-tag-filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 5px 10px;
cursor: pointer;
}
.stashpad-tag-filter-row.is-highlighted { background: var(--background-modifier-hover); }
.stashpad-tag-filter-row.is-active { font-weight: var(--font-semibold); }
.stashpad-tag-filter-row.is-active .stashpad-tag-filter-row-label::after {
content: "✓";
margin-left: 6px;
color: var(--text-accent);
}
.stashpad-tag-filter-row-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stashpad-tag-filter-row-count { color: var(--text-muted); font-size: var(--font-ui-smaller); flex: 0 0 auto; }
.stashpad-tag-filter-empty { padding: 8px 10px; color: var(--text-muted); }
@container (max-width: 360px) {
.stashpad-time-filter-btns { display: none; }
.stashpad-time-filter-select { display: block; }
@ -1036,6 +1139,22 @@
opacity: 0.55;
}
/* 0.105.0 / 0.106.x: list-pinned note floated to the top of its list. Left
accent bar; the pin indicator is a lucide icon in the meta column (under the
timestamp, before the children-count arrow) see .stashpad-note-listpin. */
.stashpad-note.is-list-pinned {
box-shadow: inset 3px 0 0 var(--text-accent);
}
.stashpad-note-listpin {
display: inline-flex;
align-items: center;
color: var(--text-accent);
}
.stashpad-note-listpin svg {
width: 13px;
height: 13px;
}
/* 0.99.5: a note sitting on a pending CUT (note clipboard) ghosted + dashed
outline, like a cut file in a file manager. Cleared when the cut is pasted
or replaced. */
@ -2636,6 +2755,20 @@
.stashpad-due-field-icon:hover { color: var(--text-normal); background: var(--interactive-hover); }
.stashpad-due-field-icon:active { background: var(--background-modifier-active-hover); }
.stashpad-due-field-icon svg { width: 16px; height: 16px; }
/* 0.104.x: dedicated × to clear a single field (date or time). */
.stashpad-due-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex: 0 0 auto;
color: var(--text-faint);
cursor: pointer;
border-radius: 5px;
}
.stashpad-due-clear:hover { color: var(--text-normal); background: var(--background-modifier-hover); }
.stashpad-due-clear svg { width: 13px; height: 13px; }
/* 0.76.8: hide the native right-side picker indicator our left
icon is the picker button now (calls showPicker). */
.stashpad-due-date::-webkit-calendar-picker-indicator,
@ -4439,6 +4572,40 @@ body.stashpad-folderpanel-resizing { cursor: row-resize; user-select: none; }
color: var(--text-accent);
}
.stashpad-folderpanel-pinmark svg { width: 12px; height: 12px; }
/* 0.105.1: folder-row chevron + inline list of the folder's Home list-pinned
notes (revealed on click). */
.stashpad-folderpanel-pinreveal {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
cursor: pointer;
color: var(--text-muted);
}
.stashpad-folderpanel-pinreveal:hover { color: var(--text-normal); }
.stashpad-folderpanel-pinlist {
display: flex;
flex-direction: column;
margin: 0 0 4px 28px;
}
.stashpad-folderpanel-pinrow {
display: flex;
align-items: center;
gap: 7px;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
color: var(--text-normal);
}
.stashpad-folderpanel-pinrow:hover { background: var(--background-modifier-hover); }
.stashpad-folderpanel-pinrow-icon { display: inline-flex; flex: 0 0 auto; color: var(--text-accent); }
.stashpad-folderpanel-pinrow-icon svg { width: 13px; height: 13px; }
.stashpad-folderpanel-pinrow-label {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--font-ui-smaller);
}
/* 0.98.25: archive-folder badge (auto-encrypt incoming). */
.stashpad-folderpanel-archive-badge {
flex: 0 0 auto; display: inline-flex; align-items: center;
@ -4489,3 +4656,48 @@ body.stashpad-folderpanel-resizing { cursor: row-resize; user-select: none; }
.stashpad-split-quick-label { font-size: var(--font-ui-smaller); color: var(--text-muted); margin-right: 2px; }
.stashpad-split-quick-btn { font-size: var(--font-ui-smaller); padding: 2px 8px; }
.stashpad-split-quick-btn:disabled { opacity: 0.5; cursor: default; }
/* --- Sheet version tabs (a row's alternate versions) --- */
.stashpad-version-tabs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--background-modifier-border);
}
.stashpad-version-tab {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 18ch;
padding: 1px 8px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background: var(--background-secondary);
color: var(--text-muted);
font-size: var(--font-ui-smaller);
cursor: pointer;
user-select: none;
}
.stashpad-version-tab:hover { color: var(--text-normal); background: var(--background-modifier-hover); }
.stashpad-version-tab.is-active {
color: var(--text-normal);
background: var(--background-primary);
border-color: var(--interactive-accent);
font-weight: var(--font-semibold);
cursor: default;
}
.stashpad-version-tab.is-final { border-color: var(--text-accent); }
.stashpad-version-tab-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stashpad-version-star { color: var(--text-accent); font-size: 0.85em; line-height: 1; }
.stashpad-version-add {
font-size: var(--font-ui-smaller);
line-height: 1;
padding: 1px 7px;
height: auto;
box-shadow: none;
color: var(--text-muted);
}
.stashpad-version-add:hover { color: var(--text-normal); }