0.91.2: folder panel, Stashpad command palette, mobile + import polish

This commit is contained in:
Human 2026-06-06 09:37:25 -07:00
parent 634f0d6a46
commit ab781538a1
20 changed files with 2181 additions and 240 deletions

143
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.84.17",
"version": "0.91.2",
"minAppVersion": "1.7.0",
"description": "Chat-style nested-notes view: rapid capture, outliner navigation, in-place editing.",
"author": "Human",

View file

@ -1,6 +1,6 @@
{
"name": "stashpad-obsidian",
"version": "0.84.17",
"version": "0.91.2",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",

126
release-notes/0.91.2.md Normal file
View file

@ -0,0 +1,126 @@
# 0.91.2 — Folder panel, Stashpad command palette, mobile + import polish
## Folder panel (0.86)
A new left-sidebar `StashpadFolderPanelView` — a one-tap folder picker that
doubles as a pin shelf.
- **Split layout:** pinned notes on top, Stashpad folders on the bottom.
Each list is independently scrollable, and the bottom half sits in
thumb-reach territory for mobile swipe-in.
- **Ribbon icon** opens it on mobile (and on desktop if you'd rather click
than chord).
- **Draggable divider** to resize the Pinned / Folders split; the ratio
persists.
- **Compact rows** with a folder badge + title; pins show their parent
blurb.
- **One-click pin open** from the panel.
- **Mobile UX:** tapping a row dismisses the sidebar; opening it doesn't
pop the composer keyboard.
## Cross-device pin sync (0.86.3)
- Pinned notes used to live in per-device plugin data (`.stashpad/data.json`),
which Obsidian Sync doesn't carry — so pins appeared on desktop but not
mobile. The pin state now lives on the note itself (`pinned: true` +
`pinnedAt` epoch-ms order key in frontmatter), so it travels with the
note.
## Stashpad command palette (0.90)
- New `StashpadCommandPalette` (default chord **`Mod+K`**) lists only this
plugin's commands — the redundant `Stashpad: ` prefix is stripped and
fuzzy-search is built in. Built from Obsidian's command registry so it
always stays in sync with the actual command list.
## "More commands…" entry (0.87.0)
- Both the ⚡ (mobile actions) menu and the right-click context menu now
have a **More commands…** entry that opens Obsidian's full command
palette pre-filtered to Stashpad — the escape hatch to all ~90
commands.
## Mobile (0.87.1, 0.89.0)
- **One action button per row** (`⋮`) opens the note context menu, which
already carries Focus / Open in editor / everything. Replaces the two
per-row edit + focus buttons that crowded narrow screens. The completed
checkbox tucks into the meta row.
- *(Experimental)* On mobile with the keyboard up, `revealCursorRow` now
clamps the visible bottom to just above the composer, so a tapped note
stays visible instead of disappearing under the keyboard.
## Search / picker polish (0.85.110.85.15)
- At ≥3 characters of query, a matching local home note is **pinned to
the top** of search/destination/move/in-parent picker results so folder
switches don't get buried.
- The local home note is now labeled **`Home — <folder>`** instead of just
`Home`, so it's matchable by folder name and stays consistent with the
cross-folder home entries.
- The "Open folder in a new tab…" shortcut is hidden once you start typing,
and kept on folder-name search at the bottom of the result list.
## Tasks panel (0.88.1)
- New **Folder** dropdown filter, AND-combined with the existing Status +
Assignment filters. Lists the distinct folders that have tasks; resets
to "all" if the chosen folder drops out.
- **View menu** in any Stashpad gains an **Imported notes only** toggle
and a **By author** filter.
## Imports retain authorship (0.88.0)
- Imports used to strip `author` / `contributors` (reserved keys), so the
new "by author" filter and "imported only" filter had no data to work
with. `importMarkdown` now retains the source's author and contributors,
and tags the note as imported.
## Update reload (0.88.2, 0.88.3)
- The "a newer build synced in" notice's Reload button used to do a
plugin disable/enable, which often left the renderer on the old cached
`main.js`. It now runs Obsidian's **Reload app without saving**
(`app:reload`) with a `window.location.reload()` fallback.
- A one-shot localStorage flag bridges the reload, and the next load
un-ghosts every deferred Stashpad tab so they paint with the fresh code
immediately.
## Export password UX (0.85.40.85.8)
- **Live strength meter** in the export modal — pool-size entropy +
repetition penalty + common-password floor. Nudge, never a gate. No new
dependency.
- **Generate strong passphrase** button — XKCD-style 5-word phrase with
separator + digit suffix.
- **Optional keychain remember** (device-local) with an explicit warning
that the remembered passphrase doesn't sync.
- **Word-phrase ceiling** so the meter doesn't mark a strong 5-word phrase
as "weak" because it lacks symbols.
- **Copy / Paste buttons per password field** — Paste when empty, Copy
when filled.
- **Export modal states the actual encryption suite** for this device
(Argon2id vs PBKDF2 fallback).
## Cross-folder moves (0.86.5, 0.86.7)
- **Re-home stranded notes.** Moving a note's file across folders in
Obsidian's file explorer left its `parent` pointing at a note in the
*old* folder — a dangling parent that orphaned the note. The fix
re-homes any note whose `parent` is missing in the new folder.
- The move modal's cross-folder path now updates the recovery wikilink
fields (`parentLink` + the source / destination parents' `children`
lists) too. The intra-folder reparent already did.
## Multi-selection survives reload (0.91.00.91.2)
- Multi-selection now persists across reload via localStorage (mirrors
last-cursor), so a deferred / lazy-loaded tab restores its selection
on first paint.
- The keymap Scope's Escape handler cancels the in-list picker instead of
immediately collapsing the multi-selection, then collapses on a second
Escape.
- Move toast no longer spams duplicates on cross-folder moves; new "moved
to <folder>" notice fires once per move.
## Other fixes
- **Task toggle staleness on slow drives** (0.85.1) — new
`taskTaggedState` override mirrors `completedState`, so flipping H after
an FM round-trip no longer reverts the next render.
- **Per-folder rebootstrap-step commands** (0.85.2) — slug pass and
frontmatter backfill can each be run on a single folder.
- **Attachments link/plain format fight** (0.85.9) — `attachments`
frontmatter no longer flip-flops between `[[link]]` form (written by
`convertAttachmentsToLinks`) and plain paths (re-written by the body
sync). Both paths now compare canonical link form.
- **Composer → destination-picker focus** on mobile (0.85.10) — the
destination button no longer schedules blind refocus timers that yanked
the iOS keyboard back onto the composer after a pick.

37
scripts/cdp.mjs Normal file
View file

@ -0,0 +1,37 @@
// Minimal CDP driver for the running Obsidian. Node 18+ globals (fetch/WebSocket).
// Usage: node scripts/cdp.mjs '<js expression returning a JSON-serializable value>'
// The expression runs with `app` in scope (async IIFE; you may await).
const expr = process.argv[2];
if (!expr) { console.error("need an expression"); process.exit(1); }
const targets = await (await fetch("http://localhost:9222/json")).json();
const page = targets.find((t) => t.type === "page" && /obsidian\.md|app:\/\/obsidian/.test(t.url))
|| targets.find((t) => t.type === "page" && t.url !== "about:blank")
|| targets[0];
if (!page) { console.error("no page target"); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
const send = (method, params) => new Promise((res, rej) => {
const mid = ++id;
pending.set(mid, { res, rej });
ws.send(JSON.stringify({ id: mid, method, params }));
});
await new Promise((res) => { ws.onopen = res; });
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && pending.has(msg.id)) {
const { res, rej } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result);
}
};
const wrapped = `(async () => { try { const __r = await (async () => { ${expr} })(); return JSON.stringify(__r); } catch (e) { return JSON.stringify({ __error: String(e && e.stack || e) }); } })()`;
const r = await send("Runtime.evaluate", { expression: wrapped, awaitPromise: true, returnByValue: true });
console.log(r.result?.value ?? JSON.stringify(r));
ws.close();
process.exit(0);

37
scripts/obs Executable file
View file

@ -0,0 +1,37 @@
#!/bin/sh
# Obsidian CLI wrapper for the Stashpad test harness.
#
# The CLI shipped in Obsidian 1.12.4 (GA Feb 2026). It's a remote control for a
# RUNNING desktop instance (not headless) — if Obsidian isn't running, the first
# command launches it. Enable once via Settings → General → Command line
# interface → Register CLI (adds `obsidian` to PATH). This wrapper also works
# BEFORE that registration by falling back to the macOS app-bundle binary.
#
# Usage:
# scripts/obs <command> [k=v ...] # any Obsidian CLI command
# scripts/obs eval code='<js>' # run JS, returns the result (awaits promises)
# scripts/obs eval-file path/to/test.js # run a JS FILE's contents (no shell-escaping pain)
# scripts/obs plugin:reload id=stashpad # reload the dev plugin after a deploy
# scripts/obs dev:screenshot path=/tmp/x.png
# scripts/obs dev:console # captured console messages
#
# `eval` JS runs with `app` in scope. For UI-driving tests, dispatch synthetic
# events inside the code (e.g. el.dispatchEvent(new KeyboardEvent(...))) — same
# as the old cdp.mjs. Keep scripts/cdp.mjs as the fallback when the CLI is
# unavailable (older Obsidian) or when a persistent CDP session is needed.
BIN="$(command -v obsidian 2>/dev/null)"
[ -n "$BIN" ] || BIN="/Applications/Obsidian.app/Contents/MacOS/obsidian-cli"
if [ ! -x "$BIN" ]; then
echo "Obsidian CLI not found. Need Obsidian 1.12.4+; enable via Settings → General → Register CLI." >&2
exit 1
fi
if [ "$1" = "eval-file" ]; then
[ -n "$2" ] && [ -f "$2" ] || { echo "eval-file: missing/unreadable JS file: $2" >&2; exit 1; }
code="$(cat "$2")"
shift 2
exec "$BIN" eval code="$code" "$@"
fi
exec "$BIN" "$@"

62
src/command-palette.ts Normal file
View file

@ -0,0 +1,62 @@
import { App, SuggestModal } from "obsidian";
import { siftMatch } from "./types";
/** One entry in the Stashpad-only command palette: an Obsidian command id
* plus its display name (with the redundant "Stashpad: " prefix stripped). */
interface PaletteCommand {
id: string;
name: string;
}
/** 0.90.0: a Stashpad-only command palette (default Mod+K). Lists just this
* plugin's commands — built straight from Obsidian's command registry by
* filtering ids that start with `stashpad:` (Obsidian namespaces every plugin
* command as `<pluginId>:<commandId>`), so it auto-covers every command with
* zero idmethod bookkeeping. Because every command is already a
* Stashpad one, the "Stashpad: " name prefix Obsidian prepends is dropped.
*
* Search uses Sift (all tokens, any order, case-insensitive substring) the
* same matcher the note/folder pickers use so it behaves like the rest of
* Stashpad's search surfaces. No filter chips (those are for note search,
* not commands). On pick we run the command by id via
* `commands.executeCommandById`, which resolves the right target the same way
* the native palette would. */
export class StashpadCommandPalette extends SuggestModal<PaletteCommand> {
private commands: PaletteCommand[];
constructor(app: App) {
super(app);
this.setPlaceholder("Run a Stashpad command…");
const registry: Record<string, { name?: string }> =
(this.app as any).commands?.commands ?? {};
this.commands = Object.keys(registry)
.filter((id) => id.startsWith("stashpad:"))
// Don't list the palette-opener inside its own palette.
.filter((id) => id !== "stashpad:stashpad-command-palette")
.map((id) => ({ id, name: stripStashpadPrefix(registry[id]?.name ?? id) }))
.sort((a, b) => a.name.localeCompare(b.name));
}
getSuggestions(query: string): PaletteCommand[] {
return this.commands.filter((c) => siftMatch(query, c.name));
}
renderSuggestion(cmd: PaletteCommand, el: HTMLElement): void {
el.createDiv({ text: cmd.name, cls: "stashpad-cmd-palette-name" });
}
onChooseSuggestion(cmd: PaletteCommand): void {
(this.app as any).commands?.executeCommandById?.(cmd.id);
}
}
/** Strip the leading "Stashpad: " that Obsidian prepends to every plugin
* command's display name. The `+` collapses repeats a couple of commands
* redundantly include "Stashpad:" in their own registered name, so Obsidian's
* auto-prefix doubles it ("Stashpad: Stashpad: …"); one pass would leave one
* behind. Case-insensitive, tolerant of extra spaces. Falls back to the
* original string if no prefix is present. */
function stripStashpadPrefix(name: string): string {
return name.replace(/^(?:\s*Stashpad:\s*)+/i, "").trim() || name;
}

View file

@ -1,7 +1,8 @@
import { FuzzySuggestModal, Notice, TFile, moment } from "obsidian";
import { ROOT_ID, type StashpadId, type TreeNode } from "../types";
import { buildStashZip, importStashZip, STASH_EXT } from "../stash-package";
import { encryptStash, resolveStashBytes, STASH_KDF_INFO } from "../stash-crypto";
import { argon2Available, encryptStash, resolveStashBytes, STASH_KDF_INFO } from "../stash-crypto";
import { secretIdForStashName } from "../passphrase";
import { ExportStashModal } from "../modals";
import type { StashpadView } from "../view";
@ -27,14 +28,14 @@ export async function cmdExportStash(view: StashpadView, rootNode?: TreeNode): P
? view.titleForNode(roots[0])
: `${folderTag}-${roots.length}notes`;
// 0.84.2: confirm name (+ later: optional encryption) in a modal first.
new ExportStashModal(view.app, defaultBase, all.length, (chosen, password) => {
void runExport(view, roots, all, chosen, password);
}).open();
new ExportStashModal(view.app, defaultBase, all.length, (chosen, password, remember) => {
void runExport(view, roots, all, chosen, password, remember);
}, argon2Available).open();
}
/** Build + write the .stash with the chosen base name, optionally encrypted,
* then notify. */
async function runExport(view: StashpadView, roots: TreeNode[], all: TreeNode[], baseName: string, password: string | null): Promise<void> {
async function runExport(view: StashpadView, roots: TreeNode[], all: TreeNode[], baseName: string, password: string | null, remember = false): Promise<void> {
try {
let buf = await buildStashZip(view.app, {
rootNotes: roots.filter((n) => !!n.file).map((n) => ({ id: n.id, file: n.file! })),
@ -61,8 +62,18 @@ async function runExport(view: StashpadView, roots: TreeNode[], all: TreeNode[],
const exportSub = (view.plugin.settings.exportFolder || "_exports").trim().replace(/^\/+|\/+$/g, "");
const exportFolder = `${view.noteFolder}/${exportSub}`;
await view.ensureFolder(exportFolder);
const outPath = `${exportFolder}/${safe}-${stamp}.${STASH_EXT}`;
const outBase = `${safe}-${stamp}`;
const outPath = `${exportFolder}/${outBase}.${STASH_EXT}`;
await view.app.vault.createBinary(outPath, buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer);
// 0.85.4: optionally remember the passphrase in this vault's secret storage
// (OS keychain), keyed deterministically by the filename so re-import on
// this device can look it up. Best-effort — a keychain failure must not
// fail the export (the file is already written + encrypted).
if (remember && password) {
const ss = (view.app as { secretStorage?: { setSecret(id: string, v: string): void } }).secretStorage;
try { ss?.setSecret(secretIdForStashName(outBase), password); }
catch (e) { console.warn("[Stashpad] couldn't save export passphrase to secret storage", e); }
}
await view.log.append({
type: "stash_export",
id: roots[0].id,
@ -135,7 +146,8 @@ export async function processStashFile(view: StashpadView, file: TFile): Promise
try {
const raw = new Uint8Array(await view.app.vault.readBinary(file));
// 0.84.3: if encrypted, prompt + decrypt before the unchanged import path.
const buf = await resolveStashBytes(view.app, raw);
// 0.85.4: try a passphrase remembered for this filename first (silent).
const buf = await resolveStashBytes(view.app, raw, { secretId: secretIdForStashName(file.basename) });
if (!buf) return; // user cancelled the password prompt — leave the file as-is
const summary = await importStashZip(view.app, buf, view.noteFolder, collectExistingIds(view));
view.tree.rebuild(view.noteFolder);

301
src/folder-panel-view.ts Normal file
View file

@ -0,0 +1,301 @@
import { App, ItemView, Menu, Modal, Notice, Platform, TFile, TFolder, WorkspaceLeaf, setIcon } from "obsidian";
import type StashpadPlugin from "./main";
import { STASHPAD_FOLDER_PANEL_VIEW_TYPE, STASHPAD_VIEW_TYPE } from "./types";
import { ConfirmModal } from "./modals";
/** 0.86.0: a left-sidebar folder picker, designed for mobile (swipe the left
* panel in, tap a folder to jump). Two stacked scrollable lists: pinned notes
* on top, Stashpad folders on the bottom (within thumb reach). Each folder row
* shows an "open" indicator, a reveal + open-in-new-tab button, and a
* right-click menu (open / reveal / rename / delete). Works on desktop too. */
export class StashpadFolderPanelView extends ItemView {
constructor(leaf: WorkspaceLeaf, private plugin: StashpadPlugin) {
super(leaf);
}
getViewType(): string { return STASHPAD_FOLDER_PANEL_VIEW_TYPE; }
getDisplayText(): string { return "Stashpad folders"; }
getIcon(): string { return "folders"; }
async onOpen(): Promise<void> {
this.render();
// Keep the "open" indicators + folder/pin lists fresh.
this.registerEvent(this.app.workspace.on("active-leaf-change", () => this.scheduleRender()));
this.registerEvent(this.app.workspace.on("layout-change", () => this.scheduleRender()));
this.registerEvent(this.app.vault.on("create", () => this.scheduleRender()));
this.registerEvent(this.app.vault.on("delete", () => this.scheduleRender()));
this.registerEvent(this.app.vault.on("rename", () => this.scheduleRender()));
this.registerEvent(this.app.metadataCache.on("changed", () => this.scheduleRender()));
}
private renderTimer: number | null = null;
private scheduleRender(): void {
if (this.renderTimer != null) return;
this.renderTimer = window.setTimeout(() => {
this.renderTimer = null;
if (this.containerEl.isConnected) this.render();
}, 100);
}
private render(): void {
const root = this.contentEl;
root.empty();
root.addClass("stashpad-folderpanel-root");
const frac = this.clampFrac(this.plugin.settings.folderPanelPinnedFraction ?? 0.5);
// --- top: pinned notes (height = saved fraction; resized via the divider) ---
const pinnedSection = root.createDiv({ cls: "stashpad-folderpanel-section stashpad-folderpanel-pinned" });
pinnedSection.style.flex = `0 0 ${(frac * 100).toFixed(2)}%`;
pinnedSection.createDiv({ cls: "stashpad-folderpanel-heading" }).setText("Pinned");
this.renderPinned(pinnedSection.createDiv({ cls: "stashpad-folderpanel-list" }));
// --- draggable divider ---
const divider = root.createDiv({ cls: "stashpad-folderpanel-divider" });
divider.createDiv({ cls: "stashpad-folderpanel-divider-grip" });
this.attachDividerDrag(root, pinnedSection, divider);
// --- bottom: folders (takes the rest; kept low for thumb reach on mobile) ---
const folderSection = root.createDiv({ cls: "stashpad-folderpanel-section stashpad-folderpanel-folders" });
folderSection.style.flex = "1 1 0";
folderSection.createDiv({ cls: "stashpad-folderpanel-heading" }).setText("Folders");
this.renderFolders(folderSection.createDiv({ cls: "stashpad-folderpanel-list" }));
}
private clampFrac(f: number): number {
if (!Number.isFinite(f)) return 0.5;
return Math.max(0.15, Math.min(0.85, f));
}
/** Drag the divider to resize the Pinned/Folders split. Pointer events cover
* both mouse and touch; the fraction is persisted on release. */
private attachDividerDrag(root: HTMLElement, pinnedSection: HTMLElement, divider: HTMLElement): void {
let pending: number | null = null;
const onMove = (ev: PointerEvent) => {
const rect = root.getBoundingClientRect();
if (rect.height <= 0) return;
const f = this.clampFrac((ev.clientY - rect.top) / rect.height);
pending = f;
pinnedSection.style.flex = `0 0 ${(f * 100).toFixed(2)}%`;
};
const onUp = (ev: PointerEvent) => {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
document.body.removeClass("stashpad-folderpanel-resizing");
try { divider.releasePointerCapture(ev.pointerId); } catch { /* noop */ }
if (pending != null) {
this.plugin.settings.folderPanelPinnedFraction = pending;
void this.plugin.saveSettings();
}
};
divider.addEventListener("pointerdown", (ev: PointerEvent) => {
ev.preventDefault();
document.body.addClass("stashpad-folderpanel-resizing");
try { divider.setPointerCapture(ev.pointerId); } catch { /* noop */ }
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
});
}
// ---------- pinned notes (top) ----------
private renderPinned(list: HTMLElement): void {
const pins = this.plugin.listPinnedNotes();
if (pins.length === 0) {
list.createDiv({ cls: "stashpad-folderpanel-empty", text: "No pinned notes yet — pin a note from its right-click menu." });
return;
}
for (const pin of pins) {
const file = pin.file;
const row = list.createEl("button", { cls: "stashpad-folderpanel-row stashpad-folderpanel-pin-row" });
setIcon(row.createSpan({ cls: "stashpad-folderpanel-row-icon" }), "pin");
row.createSpan({ cls: "stashpad-folderpanel-row-label", text: this.titleFromFile(file) });
const folderName = pin.folder.split("/").pop() || pin.folder;
row.createSpan({ cls: "stashpad-folderpanel-row-sub", text: folderName });
row.onclick = () => { this.onNavigateAway(); void this.plugin.revealNoteInStashpad(file); };
}
}
/** On mobile, jumping out of the panel should reveal the destination collapse
* the left sidebar WITHOUT the target view popping its composer keyboard. */
private onNavigateAway(): void {
if (!Platform.isMobile) return;
this.plugin.suppressComposerAutofocusUntil = Date.now() + 1500;
this.app.workspace.leftSplit?.collapse?.();
}
private titleFromFile(file: TFile): string {
return file.basename.replace(/-[a-z0-9]{4,12}$/, "").replace(/-/g, " ").trim() || file.basename;
}
// ---------- folders (bottom) ----------
/** Folder paths (trailing-slash-stripped) that currently have an open
* Stashpad tab. */
private openFolders(): Set<string> {
const set = new Set<string>();
for (const leaf of this.app.workspace.getLeavesOfType(STASHPAD_VIEW_TYPE)) {
const f = ((leaf.view as any)?.noteFolder ?? "").replace(/\/+$/, "");
if (f) set.add(f);
}
return set;
}
private renderFolders(list: HTMLElement): void {
const folders = this.plugin.discoverStashpadFolders();
if (folders.length === 0) {
list.createDiv({ cls: "stashpad-folderpanel-empty", text: "No Stashpad folders yet." });
return;
}
const open = this.openFolders();
for (const folder of folders) {
const isOpen = open.has(folder.replace(/\/+$/, ""));
const row = list.createDiv({ cls: "stashpad-folderpanel-row stashpad-folderpanel-folder-row" });
if (isOpen) row.addClass("is-open");
const dot = row.createSpan({ cls: "stashpad-folderpanel-dot" });
dot.setAttr("aria-label", isOpen ? "Open in a tab" : "Not open");
if (isOpen) dot.setAttr("title", "Open in a tab");
const name = folder.split("/").pop() || folder;
row.createSpan({ cls: "stashpad-folderpanel-row-label", text: name });
const actions = row.createDiv({ cls: "stashpad-folderpanel-actions" });
const revealBtn = actions.createEl("button", { cls: "stashpad-folderpanel-iconbtn" });
setIcon(revealBtn, "folder-search");
revealBtn.setAttr("aria-label", "Reveal in file explorer");
revealBtn.onclick = (e) => { e.stopPropagation(); this.revealFolder(folder); };
const newTabBtn = actions.createEl("button", { cls: "stashpad-folderpanel-iconbtn" });
setIcon(newTabBtn, "plus-square");
newTabBtn.setAttr("aria-label", "Open in new tab");
newTabBtn.onclick = (e) => { e.stopPropagation(); this.onNavigateAway(); void this.plugin.activateViewForFolder(folder); };
// Tapping the row jumps to the folder (reusing an open tab if there is one).
row.onclick = () => { this.onNavigateAway(); this.jumpToFolder(folder); };
row.oncontextmenu = (e) => { e.preventDefault(); this.openFolderMenu(e, folder); };
}
}
/** Reuse an existing Stashpad tab on this folder if present; else open one. */
private jumpToFolder(folder: string): void {
const cleaned = folder.replace(/\/+$/, "");
const existing = this.app.workspace.getLeavesOfType(STASHPAD_VIEW_TYPE)
.find((l) => (((l.view as any)?.noteFolder ?? "").replace(/\/+$/, "")) === cleaned);
if (existing) { this.app.workspace.revealLeaf(existing); return; }
void this.plugin.activateViewForFolder(folder);
}
private revealFolder(folder: string): void {
const tf = this.app.vault.getAbstractFileByPath(folder.replace(/\/+$/, ""));
if (!(tf instanceof TFolder)) { new Notice("Couldn't find that folder."); return; }
const leaf = this.app.workspace.getLeavesOfType("file-explorer")[0];
if (!leaf) { new Notice("File explorer isn't available."); return; }
this.app.workspace.revealLeaf(leaf);
(leaf.view as any)?.revealInFolder?.(tf);
}
private openFolderMenu(e: MouseEvent, folder: string): void {
const menu = new Menu();
// Order: non-destructive navigation first, then rename, then delete (isolated).
menu.addItem((i) => i.setTitle("Open in new tab").setIcon("plus-square")
.onClick(() => void this.plugin.activateViewForFolder(folder)));
menu.addItem((i) => i.setTitle("Reveal in file explorer").setIcon("folder-search")
.onClick(() => this.revealFolder(folder)));
menu.addSeparator();
menu.addItem((i) => i.setTitle("Rename…").setIcon("pencil")
.onClick(() => this.renameFolder(folder)));
menu.addSeparator();
menu.addItem((i) => {
i.setTitle("Delete folder…").setIcon("trash").onClick(() => this.deleteFolder(folder));
(i as any).setWarning?.(true);
});
menu.showAtMouseEvent(e);
}
private renameFolder(folder: string): void {
const cleaned = folder.replace(/\/+$/, "");
const tf = this.app.vault.getAbstractFileByPath(cleaned);
if (!(tf instanceof TFolder)) { new Notice("Couldn't find that folder."); return; }
const current = tf.name;
new RenameFolderModal(this.app, current, async (next) => {
const safe = next.trim().replace(/[\\/:]+/g, "").trim();
if (!safe || safe === current) return;
const parent = tf.parent?.path && tf.parent.path !== "/" ? `${tf.parent.path}/` : "";
const target = `${parent}${safe}`;
if (this.app.vault.getAbstractFileByPath(target)) { new Notice(`"${safe}" already exists.`); return; }
try {
await this.app.fileManager.renameFile(tf, target);
// Keep the configured default folder pointing at the renamed path.
if ((this.plugin.settings.folder || "").replace(/\/+$/, "") === cleaned) {
this.plugin.settings.folder = target;
await this.plugin.saveSettings();
}
new Notice(`Renamed to "${safe}".`);
} catch (err) {
console.warn("[Stashpad] folder rename failed", err);
new Notice("Rename failed (see console).");
}
}).open();
}
private deleteFolder(folder: string): void {
const cleaned = folder.replace(/\/+$/, "");
const tf = this.app.vault.getAbstractFileByPath(cleaned);
if (!(tf instanceof TFolder)) { new Notice("Couldn't find that folder."); return; }
const noteCount = this.app.vault.getMarkdownFiles()
.filter((f) => (f.parent?.path?.replace(/\/+$/, "") ?? "") === cleaned
|| (f.path.startsWith(cleaned + "/"))).length;
const name = tf.name;
new ConfirmModal(
this.app,
`Delete "${name}"?`,
`This moves the entire folder — about ${noteCount} note${noteCount === 1 ? "" : "s"} plus its attachments and exports — to the trash.\nYou can restore it from your system/Obsidian trash.`,
"Delete folder",
async (confirmed) => {
if (!confirmed) return;
try {
await this.app.fileManager.trashFile(tf);
new Notice(`Deleted "${name}".`);
} catch (err) {
console.warn("[Stashpad] folder delete failed", err);
new Notice("Delete failed (see console).");
}
},
).open();
}
}
/** Tiny single-input modal for renaming a folder. */
class RenameFolderModal extends Modal {
private delivered = false;
constructor(app: App, private current: string, private onSubmit: (next: string) => void) {
super(app);
}
onOpen(): void {
this.modalEl.addClass("stashpad-compact-modal");
this.contentEl.empty();
this.titleEl.setText("Rename folder");
const input = this.contentEl.createEl("input", { type: "text" }) as HTMLInputElement;
input.addClass("stashpad-folderpanel-rename-input");
input.value = this.current;
const footer = this.contentEl.createDiv({ cls: "stashpad-folderpanel-rename-footer" });
footer.createEl("button", { text: "Cancel" }).onclick = () => this.close();
const go = footer.createEl("button", { cls: "mod-cta", text: "Rename" });
const submit = () => { this.delivered = true; const v = input.value; this.close(); this.onSubmit(v); };
go.onclick = submit;
this.scope.register([], "Enter", (e) => { e.preventDefault(); if (input.value.trim()) submit(); });
requestAnimationFrame(() => { input.focus(); input.select(); });
}
onClose(): void { this.contentEl.empty(); }
}
/** Open the folder panel in the LEFT sidebar (reuse if already open). */
export async function openFolderPanelView(app: App): Promise<void> {
const existing = app.workspace.getLeavesOfType(STASHPAD_FOLDER_PANEL_VIEW_TYPE);
if (existing.length > 0) { app.workspace.revealLeaf(existing[0]); return; }
const leaf = app.workspace.getLeftLeaf(false);
if (!leaf) { new Notice("Stashpad: couldn't open the folder panel."); return; }
await leaf.setViewState({ type: STASHPAD_FOLDER_PANEL_VIEW_TYPE, active: true });
app.workspace.revealLeaf(leaf);
}

View file

@ -5,6 +5,7 @@ import { newId } from "./id-service";
import { bodyToSlug, buildFilename } from "./slug-service";
import { splitFrontmatter, serializeNote, importStashZip, STASH_EXT } from "./stash-package";
import { resolveStashBytes, isEncryptedStash } from "./stash-crypto";
import { secretIdForStashName } from "./passphrase";
import { ConfirmModal, ImportDupChoiceModal } from "./modals";
/** Reserved subfolders inside a Stashpad folder never treated as drop
@ -306,6 +307,12 @@ export class ImportService {
cloneFm.created = t.created;
if (t.modified) cloneFm.modified = t.modified;
cloneFm.attachments = Array.isArray(fm.attachments) ? fm.attachments : [];
// 0.88.0: RETAIN the source's authorship on import. `author`/`contributors`
// are normally reserved/stripped, but an imported note should keep WHO
// wrote it — the importer only becomes a contributor once they edit it.
if (fm.author !== undefined) cloneFm.author = fm.author;
if (fm.contributors !== undefined) cloneFm.contributors = fm.contributors;
cloneFm.imported = true; // mark as imported (the "imported only" view filter)
const slug = bodyToSlug(body) || file.basename;
const notePath = await this.uniquePath(folder, buildFilename(slug, cloneFm.id));
@ -331,6 +338,7 @@ export class ImportService {
created: new Date().toISOString(),
// 0.79.18: attachments stored as internal links (not plain text).
attachments: [toAttachmentLink(attachmentPath)],
imported: true, // 0.88.0: mark as imported (the "imported only" view filter)
};
// 0.79.18: embed the attachment (! prefix) so it previews inline.
const body = `${title}\n\n![[${attachmentPath}]]\n`;
@ -827,7 +835,8 @@ export class ImportService {
this.pendingEncryptedStashes.add(file.path); // defer — surfaced by the sweep
return false;
}
const buf = await resolveStashBytes(this.app, rawBytes, promptOpts); // prompts if encrypted
// 0.85.4: try a passphrase remembered for this filename before prompting.
const buf = await resolveStashBytes(this.app, rawBytes, { ...promptOpts, secretId: secretIdForStashName(file.basename) });
if (!buf) return false; // cancelled / "remind me later" — leave the file
const summary = await importStashZip(this.app, buf, root, existingIds);
try { await this.app.fileManager.trashFile(file); } catch {}

View file

@ -1,8 +1,9 @@
import { Notice, Platform, Plugin, SuggestModal, TFile, TFolder, WorkspaceLeaf, setIcon } from "obsidian";
import { STASHPAD_DETAIL_VIEW_TYPE, STASHPAD_PANELS_VIEW_TYPE, STASHPAD_VIEW_TYPE, parseAuthorRef, toAttachmentLink, isInReservedSubfolder, type PinnedNoteRef, type StashpadId } from "./types";
import { STASHPAD_DETAIL_VIEW_TYPE, STASHPAD_FOLDER_PANEL_VIEW_TYPE, STASHPAD_PANELS_VIEW_TYPE, STASHPAD_VIEW_TYPE, parseAuthorRef, toAttachmentLink, isInReservedSubfolder, type PinnedNoteRef, type StashpadId } from "./types";
import { StashpadDetailView, openStashpadDetailView } from "./detail-view";
import { StashpadView, properCaseFolderPath } from "./view";
import { StashpadPanelsView, openStashpadPanelsView, PANEL_REGISTRY, type PanelId } from "./panels-view";
import { StashpadFolderPanelView, openFolderPanelView } from "./folder-panel-view";
import {
DEFAULT_SETTINGS, StashpadSettings, StashpadSettingTab, setSettings, SETTINGS_TABS,
buildDefaultBindings, COMMAND_META, type CommandBindingMap, type CommandId,
@ -22,6 +23,10 @@ import { ImportLog } from "./import-log";
import { perf } from "./perf";
import { RenderCacheStore } from "./render-cache-store";
/** 0.89.1: localStorage key set right before an update-triggered app reload so
* the next load knows to un-ghost the deferred Stashpad tabs. */
const UNGHOST_FLAG = "stashpad:unghost-after-reload";
export default class StashpadPlugin extends Plugin {
settings: StashpadSettings = { ...DEFAULT_SETTINGS };
private undoStacks = new Map<string, UndoStack>();
@ -34,6 +39,11 @@ export default class StashpadPlugin extends Plugin {
* wikilink rewrites Obsidian does when slug-renames move files never
* bump `modified` (or add the local user as a contributor). */
rebootstrapInProgress = false;
/** 0.86.6: while `Date.now() < this`, a Stashpad view's activation
* auto-focus skips grabbing the composer. Set by the folder panel before it
* reveals/opens a leaf so tapping a pinned note on mobile doesn't pop the
* keyboard. */
suppressComposerAutofocusUntil = 0;
/** 0.73.10: keep a handle on the settings tab so command-palette
* entries can pre-select a specific tab when opening Settings. */
settingTab: StashpadSettingTab | null = null;
@ -536,6 +546,12 @@ export default class StashpadPlugin extends Plugin {
// is auto-cleared on unload.
window.setTimeout(() => void this.runAutoImportSweep(), 5000);
this.registerInterval(window.setInterval(() => void this.runAutoImportSweep(), 5 * 60 * 1000));
// 0.86.3: migrate legacy per-device pinned list → note frontmatter (so
// pins sync). After the metadata cache has settled so fileForPin resolves.
window.setTimeout(() => void this.migratePinnedNotesToFrontmatter(), 3000);
// 0.89.1: if this load follows our update-reload, un-ghost the deferred
// Stashpad tabs so they render with the fresh code (no blank tabs/buttons).
window.setTimeout(() => void this.unghostStashpadTabsIfFlagged(), 1200);
});
this.registerView(
@ -552,6 +568,10 @@ export default class StashpadPlugin extends Plugin {
STASHPAD_PANELS_VIEW_TYPE,
(leaf: WorkspaceLeaf) => new StashpadPanelsView(leaf, this),
);
this.registerView(
STASHPAD_FOLDER_PANEL_VIEW_TYPE,
(leaf: WorkspaceLeaf) => new StashpadFolderPanelView(leaf, this),
);
// 0.68.1: track the most-recently-active Stashpad leaf so the
// sidebar panel's Search / Home buttons target the leaf the user
// last worked in — not "leaves[0]" (= leftmost tab) which has
@ -720,6 +740,11 @@ export default class StashpadPlugin extends Plugin {
this.addRibbonIcon("panel-left", "Open Stashpad panels (sidebar)", () => {
void openStashpadPanelsView(this.app);
});
// 0.86.1: folder panel ribbon entry — the main way to open it on mobile
// (commands are buried; the other sidebar panels are reached via ribbon).
this.addRibbonIcon("folders", "Open Stashpad folder panel (sidebar)", () => {
void openFolderPanelView(this.app);
});
// 0.74.1: right-sidebar detail panel ribbon entry.
this.addRibbonIcon("panel-right", "Open Stashpad detail panel (right sidebar)", () => {
void openStashpadDetailView(this.app);
@ -757,6 +782,11 @@ export default class StashpadPlugin extends Plugin {
name: "Toggle split-on-newlines",
callback: () => call("toggleSplit"),
});
this.addCommand({
id: "stashpad-command-palette",
name: "Command palette (Stashpad only)",
callback: () => call("openStashpadCommandPalette"),
});
// 0.77.8: claim authorship retroactively (for notes created before the
// user set their author name). Author-only variants only fill blank
// author fields; the "+ contributor" variants also add the user as a
@ -913,6 +943,30 @@ export default class StashpadPlugin extends Plugin {
return true;
},
});
// 0.85.2: per-step counterparts to rebootstrap, scoped to the current
// folder — so you can re-run just one repair pass without the heavy
// full-vault sweep. They call the SAME functions rebootstrap uses, so a
// fix in one place applies to both.
this.addCommand({
id: "stashpad-rerun-slug-pass",
name: "Re-run filename (slug) pass on this folder",
checkCallback: (checking: boolean) => {
const folder = this.importService.defaultDestination();
if (checking) return !!folder;
if (folder) void this.runFolderSlugPass(folder);
return true;
},
});
this.addCommand({
id: "stashpad-rerun-frontmatter-backfill",
name: "Re-run frontmatter backfill (recovery links) on this folder",
checkCallback: (checking: boolean) => {
const folder = this.importService.defaultDestination();
if (checking) return !!folder;
if (folder) void this.runFolderFrontmatterBackfill(folder);
return true;
},
});
this.addCommand({ id: "stashpad-select-all", name: "Select all visible notes", callback: () => call("cmdSelectAll") });
this.addCommand({ id: "stashpad-copy-codeblock", name: "Copy code from codeblock", callback: () => call("cmdCopyCodeBlock") });
// 0.68.0: open the sidebar panels view (Pinned Notes + future panels).
@ -921,6 +975,12 @@ export default class StashpadPlugin extends Plugin {
name: "Open Stashpad panels (sidebar)",
callback: () => void openStashpadPanelsView(this.app),
});
// 0.86.0: open the left-sidebar folder picker (pinned notes + folders).
this.addCommand({
id: "stashpad-open-folder-panel",
name: "Open folder panel (sidebar)",
callback: () => void openFolderPanelView(this.app),
});
// 0.74.1: open the right-sidebar detail panel.
this.addCommand({
id: "stashpad-open-detail",
@ -1373,6 +1433,15 @@ export default class StashpadPlugin extends Plugin {
this.registerEvent(this.app.vault.on("rename", (file) => {
if (file instanceof TFile) { onMaybeOrphan(file); onMaybeMarkdownImport(file); }
}));
// 0.86.5: when a note's FILE moves to a DIFFERENT folder (e.g. dragged in
// Obsidian's file explorer), its `parent` still points at a note in the OLD
// folder — a dangling parent that orphans it in the new one. The
// missing-parent orphan-fix above doesn't catch this (the parent value is
// present, just invalid here), so re-home such notes to Home. Gated on a
// real cross-folder move, so in-folder reparents/renames are untouched.
this.registerEvent(this.app.vault.on("rename", (file, oldPath) => {
if (file instanceof TFile) this.maybeReHomeOnCrossFolderMove(file, oldPath);
}));
// 0.79.1: auto-import — any file appearing directly in a Stashpad
// folder root (not a reserved subfolder, not an existing note) gets
@ -1411,8 +1480,10 @@ export default class StashpadPlugin extends Plugin {
* from manifest.json at launch) against the manifest.json currently
* on disk. If they differ, a different build has synced in since
* launch and the user is running stale code surface a persistent
* notice with a Reload action (disable+enable re-reads main.js,
* works on mobile too). Notifies once per detected on-disk version. */
* notice with a Reload action. 0.89.1: the action now runs the FULL app
* reload ("Reload app without saving") plugin disable/enable often left
* the renderer on the cached old main.js, so the update "didn't take".
* Notifies once per detected on-disk version. */
private notifiedBuildVersion: string | null = null;
private async checkForSyncedBuild(): Promise<void> {
try {
@ -1433,21 +1504,13 @@ export default class StashpadPlugin extends Plugin {
if (this.notifiedBuildVersion === onDisk) return;
this.notifiedBuildVersion = onDisk;
this.notifications.show({
message: `A newer Stashpad build synced in (\`${loaded}\`\`${onDisk}\`). Reload to apply.`,
message: `A newer Stashpad build synced in (\`${loaded}\`\`${onDisk}\`). Reload the app to apply it.`,
kind: "info",
category: "system",
duration: 0,
actions: [{
label: "Reload Stashpad",
onClick: async () => {
const plugins = (this.app as any).plugins;
try {
await plugins?.disablePlugin?.(this.manifest.id);
await plugins?.enablePlugin?.(this.manifest.id);
} catch (e) {
new Notice(`Couldn't reload — toggle Stashpad off/on in settings. (${(e as Error).message})`);
}
},
label: "Reload app",
onClick: () => this.reloadAppForUpdate(),
}],
});
} catch (e) {
@ -1455,6 +1518,46 @@ export default class StashpadPlugin extends Plugin {
}
}
/** 0.89.1: full app reload ("Reload app without saving") the reliable way to
* pick up a freshly-synced build. Plugin disable/enable often left the
* renderer on the cached old main.js, so the update wouldn't take. Falls back
* to a raw window reload if the command isn't available. */
private reloadAppForUpdate(): void {
// 0.89.1: leave a one-shot flag so the NEXT load un-ghosts deferred Stashpad
// tabs. Obsidian defers inactive leaves on launch; after an update reload
// they'd otherwise sit as blank "ghost" tabs (and dead buttons) until tapped.
// (We can't activate them here — app:reload tears down this JS context.)
try { window.localStorage?.setItem(UNGHOST_FLAG, "1"); } catch { /* private mode */ }
try {
if ((this.app as any).commands?.executeCommandById?.("app:reload")) return;
} catch (e) {
console.warn("[Stashpad] app:reload command failed", e);
}
try {
window.location.reload();
} catch {
new Notice("Reload Obsidian (close + reopen) to apply the Stashpad update.");
}
}
/** 0.89.1: if the last reload was our update-reload, load every deferred
* Stashpad leaf so the tabs render with the fresh code instead of showing as
* blank ghosts. One-shot (clears the flag); only un-ghosts OUR view type. */
private async unghostStashpadTabsIfFlagged(): Promise<void> {
let flagged = false;
try { flagged = window.localStorage?.getItem(UNGHOST_FLAG) === "1"; } catch { /* ignore */ }
if (!flagged) return;
try { window.localStorage?.removeItem(UNGHOST_FLAG); } catch { /* ignore */ }
for (const leaf of this.app.workspace.getLeavesOfType(STASHPAD_VIEW_TYPE)) {
try {
const l = leaf as any;
if (l.isDeferred && typeof l.loadIfDeferred === "function") await l.loadIfDeferred();
} catch (e) {
console.warn("[Stashpad] un-ghost leaf failed", e);
}
}
}
/** Tiny semver-ish compare: is `a` greater than `b`? Pads to equal
* length, numeric per segment. Non-numeric segments compare as 0. */
private isSemverGreater(a: string, b: string): boolean {
@ -1676,6 +1779,57 @@ export default class StashpadPlugin extends Plugin {
}
}
/** 0.86.5: paths with a pending re-home check (de-dupes burst rename events). */
private reHomePending = new Set<string>();
/** A markdown note's file just moved. If it landed in a DIFFERENT Stashpad
* folder and its `parent` points at a note that isn't in the new folder,
* re-home it to ROOT after a debounce (lets the metadata cache settle and
* lets a Stashpad-initiated move stamp the correct parent first). */
private maybeReHomeOnCrossFolderMove(file: TFile, oldPath: string): void {
if (file.extension !== "md") return;
const newDir = file.parent?.path?.replace(/\/+$/, "") ?? "";
const slash = oldPath.lastIndexOf("/");
const oldDir = (slash >= 0 ? oldPath.slice(0, slash) : "").replace(/\/+$/, "");
if (newDir === oldDir) return; // not a cross-folder move
if (!this.discoverStashpadFolders().includes(newDir)) return; // not moved into a Stashpad
if (this.reHomePending.has(file.path)) return;
this.reHomePending.add(file.path);
setTimeout(() => {
this.reHomePending.delete(file.path);
void this.reHomeDanglingParent(file, newDir);
}, 900);
}
/** Set `parent` to ROOT iff the note's current parent is a non-ROOT id with
* no matching note in `dir`. Conservative: a present, resolvable parent (and
* one Stashpad's own move already fixed) is left alone. */
private async reHomeDanglingParent(file: TFile, dir: string): Promise<void> {
try {
const fm = this.app.metadataCache.getFileCache(file)?.frontmatter as
| { id?: unknown; parent?: unknown } | undefined;
const id = typeof fm?.id === "string" ? fm.id.trim() : "";
const parent = typeof fm?.parent === "string" ? fm.parent.trim() : "";
if (!id || !parent || parent === ROOT_ID) return; // no id / no or home parent
const parentInFolder = this.app.vault.getMarkdownFiles().some((f) =>
(f.parent?.path?.replace(/\/+$/, "") ?? "") === dir
&& this.app.metadataCache.getFileCache(f)?.frontmatter?.id === parent);
if (parentInFolder) return; // parent resolves here — fine
await this.app.fileManager.processFrontMatter(file, (m: any) => {
// re-check against disk truth (cache may have lagged)
const cur = typeof m.parent === "string" ? m.parent.trim() : "";
if (cur && cur !== ROOT_ID) m.parent = ROOT_ID;
});
await this.newLog().append({
type: "parent_change", id,
payload: { from: parent, to: ROOT_ID, reason: "rehome_cross_folder_move", path: file.path },
});
new Notice(`Re-homed ${file.basename} → Home (its parent isn't in this folder)`);
} catch (e) {
console.warn("[Stashpad] re-home on cross-folder move failed", e);
}
}
private async autoImportStash(file: TFile, destFolder: string): Promise<void> {
try {
const raw = new Uint8Array(await this.app.vault.readBinary(file));
@ -1984,27 +2138,91 @@ export default class StashpadPlugin extends Plugin {
return { folder, id: node.id, file: node.file };
}
/** Pin a note to the sidebar panels' Pinned Notes list. Idempotent. */
/** 0.86.3: pin state lives in the NOTE'S frontmatter (`pinned: true` +
* `pinnedAt` epoch-ms order key) so it syncs with the note across devices,
* rather than in per-device plugin data. */
fileForPin(folder: string, id: string): TFile | null {
const dir = folder.replace(/\/+$/, "");
for (const f of this.app.vault.getMarkdownFiles()) {
if ((f.parent?.path?.replace(/\/+$/, "") ?? "") !== dir) continue;
if (this.app.metadataCache.getFileCache(f)?.frontmatter?.id === id) return f;
}
return null;
}
/** Pin a note. Idempotent — writes `pinned: true` + `pinnedAt` to its FM. */
async pinNote(pin: PinnedNoteRef): Promise<void> {
const list = this.settings.pinnedNotes ?? [];
if (list.some((p) => p.folder === pin.folder && p.id === pin.id)) return;
this.settings.pinnedNotes = [...list, pin];
await this.saveSettings();
const file = this.fileForPin(pin.folder, pin.id);
if (!file) return;
if (this.app.metadataCache.getFileCache(file)?.frontmatter?.pinned === true) return;
await this.app.fileManager.processFrontMatter(file, (fm: any) => {
fm.pinned = true;
fm.pinnedAt = Date.now();
});
this.refreshPanelsView();
}
/** Remove a pin. Idempotent. */
/** Remove a pin (clears the frontmatter keys). Idempotent. */
async unpinNote(pin: PinnedNoteRef): Promise<void> {
const list = this.settings.pinnedNotes ?? [];
const next = list.filter((p) => !(p.folder === pin.folder && p.id === pin.id));
if (next.length === list.length) return;
this.settings.pinnedNotes = next;
await this.saveSettings();
const file = this.fileForPin(pin.folder, pin.id);
if (!file) return;
if (this.app.metadataCache.getFileCache(file)?.frontmatter?.pinned !== true) return;
await this.app.fileManager.processFrontMatter(file, (fm: any) => {
delete fm.pinned;
delete fm.pinnedAt;
});
this.refreshPanelsView();
}
isPinned(pin: PinnedNoteRef): boolean {
return (this.settings.pinnedNotes ?? []).some((p) => p.folder === pin.folder && p.id === pin.id);
const file = this.fileForPin(pin.folder, pin.id);
if (!file) return false;
return this.app.metadataCache.getFileCache(file)?.frontmatter?.pinned === true;
}
/** All pinned notes across discovered Stashpad folders, ordered by `pinnedAt`
* (then path for stability). One metadata-cache scan backs both the panels
* Pinned section and the folder panel. */
listPinnedNotes(): Array<{ folder: string; id: string; pinnedAt: number; file: TFile }> {
const folders = new Set(this.discoverStashpadFolders());
const out: Array<{ folder: string; id: string; pinnedAt: number; file: TFile }> = [];
for (const f of this.app.vault.getMarkdownFiles()) {
const dir = f.parent?.path?.replace(/\/+$/, "") ?? "";
if (!folders.has(dir)) continue;
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter as any;
if (!fm || fm.pinned !== true || typeof fm.id !== "string" || !fm.id) continue;
const at = typeof fm.pinnedAt === "number" ? fm.pinnedAt : 0;
out.push({ folder: dir, id: fm.id, pinnedAt: at, file: f });
}
out.sort((a, b) => a.pinnedAt - b.pinnedAt || a.file.path.localeCompare(b.file.path));
return out;
}
/** 0.86.3: one-time migration convert the old per-device
* `settings.pinnedNotes` list into `pinned`/`pinnedAt` frontmatter so pins
* sync. Runs after layout-ready (metadata cache settled); clears the setting
* when done so it never re-runs. */
private async migratePinnedNotesToFrontmatter(): Promise<void> {
const list = this.settings.pinnedNotes ?? [];
if (list.length === 0) return;
let stamp = Date.now() - list.length * 1000; // preserve order via increasing ts
for (const pin of list) {
const file = this.fileForPin(pin.folder, pin.id);
if (!file) { stamp += 1000; continue; }
try {
if (this.app.metadataCache.getFileCache(file)?.frontmatter?.pinned !== true) {
const at = stamp;
await this.app.fileManager.processFrontMatter(file, (fm: any) => {
fm.pinned = true;
fm.pinnedAt = at;
});
}
} catch (e) { console.warn("[Stashpad] pin migration failed for", pin, e); }
stamp += 1000;
}
this.settings.pinnedNotes = [];
await this.saveSettings();
this.refreshPanelsView();
}
/** Force any open panels view to re-render — used after pin/unpin. */
@ -2353,8 +2571,28 @@ export default class StashpadPlugin extends Plugin {
return;
}
await this.activateViewForFolder(folder);
const view = this.lastActiveStashpadLeaf?.view as any;
if (typeof view?.navigateTo === "function") view.navigateTo(id);
// 0.86.4: the freshly-opened view may still be loading its tree — navigate
// once it's actually ready, so a pinned note opens in ONE click instead of
// landing on Home (the first click) and only navigating on the second.
this.navigateWhenReady(folder, id);
}
/** Poll briefly for the folder's Stashpad view to have `id` in its tree, then
* navigate. One-click open for a not-yet-open folder. */
private navigateWhenReady(folder: string, id: string, attempts = 15): void {
const clean = folder.replace(/\/+$/, "");
const view = (this.app.workspace.getLeavesOfType(STASHPAD_VIEW_TYPE)
.find((l) => (((l.view as any)?.noteFolder ?? "").replace(/\/+$/, "")) === clean)?.view
?? this.lastActiveStashpadLeaf?.view) as any;
if (view && typeof view.navigateTo === "function") {
const treeReady = !view.tree || typeof view.tree.get !== "function" || !!view.tree.get(id);
if (treeReady) { view.navigateTo(id); return; }
}
if (attempts > 0) {
window.setTimeout(() => this.navigateWhenReady(folder, id, attempts - 1), 90);
} else if (view && typeof view.navigateTo === "function") {
view.navigateTo(id); // last resort — navigate anyway
}
}
/** Walk vault markdown frontmatter for notes whose author or
@ -2575,13 +2813,22 @@ export default class StashpadPlugin extends Plugin {
if (isInReservedSubfolder(f.path)) continue; // skip _archive/_attachments/…
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter as any;
if (!fm || typeof fm.id !== "string" || !fm.id) continue;
if (!Array.isArray(fm.attachments)) continue;
const needs = fm.attachments.some((a: any) => typeof a === "string" && a.trim() && !isLink(a));
const att: any = fm.attachments;
const isArr = Array.isArray(att);
const isScalar = typeof att === "string" && att.trim().length > 0;
if (!isArr && !isScalar) continue;
// 0.85.9: also handle a SCALAR `attachments:` (hand-edited / odd import)
// — normalize it to a one-item list of the canonical link form.
const needs = isScalar
? !isLink(att as string)
: (att as any[]).some((a: any) => typeof a === "string" && a.trim() && !isLink(a));
if (!needs) continue;
try {
await this.app.fileManager.processFrontMatter(f, (m: any) => {
if (Array.isArray(m.attachments)) {
m.attachments = m.attachments.map((a: any) => (typeof a === "string" && a.trim()) ? toAttachmentLink(a) : a);
} else if (typeof m.attachments === "string" && m.attachments.trim()) {
m.attachments = [toAttachmentLink(m.attachments)];
}
});
converted++;
@ -2677,6 +2924,53 @@ export default class StashpadPlugin extends Plugin {
});
}
/** Refresh the active view if it's looking at `folder` (so per-step repairs
* show immediately, like the loose-import command does). */
private refreshViewIfShowing(folder: string): void {
const view = this.lastActiveStashpadLeaf?.view as any;
if (view?.noteFolder === folder && view?.tree) { view.tree.rebuild(folder); view.render?.(); }
}
/** 0.85.2: re-run just the filename/slug pass on one folder the same
* `rebootstrapFolderSlugs` rebootstrap uses, without the full-vault sweep. */
async runFolderSlugPass(folder: string): Promise<void> {
const label = folder.split("/").pop() || folder;
let n = 0;
try { n = await this.rebootstrapFolderSlugs(folder); }
catch (e) {
this.notifications.show({ message: `Stashpad: slug pass failed in \`${label}\`\n${(e as Error).message}`, kind: "error", category: "system", folder });
console.error("[Stashpad] runFolderSlugPass failed", folder, e);
return;
}
this.refreshViewIfShowing(folder);
this.notifications.show({
message: n > 0
? `Renamed ${n} stale filename${n === 1 ? "" : "s"} in \`${label}\`.`
: `No stale filenames in \`${label}\` — all slugs match their notes.`,
kind: n > 0 ? "success" : "info", category: "system", folder,
});
}
/** 0.85.2: re-run just the frontmatter backfill (redundant `parentLink` /
* `children` recovery links) on one folder the same
* `rebootstrapFolderFrontmatter` rebootstrap uses. */
async runFolderFrontmatterBackfill(folder: string): Promise<void> {
const label = folder.split("/").pop() || folder;
let written = 0, checked = 0;
try { const s = await rebootstrapFolderFrontmatter(this.app, folder); written = s.written; checked = s.checked; }
catch (e) {
this.notifications.show({ message: `Stashpad: frontmatter backfill failed in \`${label}\`\n${(e as Error).message}`, kind: "error", category: "system", folder });
console.error("[Stashpad] runFolderFrontmatterBackfill failed", folder, e);
return;
}
this.notifications.show({
message: written > 0
? `Backfilled recovery links on ${written} note${written === 1 ? "" : "s"} in \`${label}\` (${checked} checked).`
: `Recovery links already up to date in \`${label}\` (${checked} checked).`,
kind: written > 0 ? "success" : "info", category: "system", folder,
});
}
private autoSweepInProgress = false;
/** 0.84.11: retroactive auto-import. Periodically (and once at startup) sweep
* EVERY Stashpad folder for non-imported loose content the "watcher
@ -2991,6 +3285,47 @@ export default class StashpadPlugin extends Plugin {
}
}
// 0.91.0: last MULTI-SELECTION per (folder, focus), persisted to
// localStorage. Mirrors the last-cursor store above. Lives in localStorage
// rather than view state because (a) it must survive even when the tab is
// lazy-loaded/deferred on reload, and (b) selection changes don't trigger a
// workspace-layout save, so getState() would capture stale state. Stamped on
// beforeunload/blur/onClose (eager), read back on view load.
private readonly LAST_SELECTION_LS_KEY = "stashpad:last-selection";
private readLastSelectionFile(): Record<string, Record<string, string[]>> {
try {
const raw = window.localStorage.getItem(this.LAST_SELECTION_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 <focusId> → <selected note ids> for the given folder. */
loadLastSelection(folder: string): Map<string, string[]> {
const all = this.readLastSelectionFile();
const slice = all[folder] ?? {};
const out = new Map<string, string[]>();
for (const [focusId, ids] of Object.entries(slice)) {
if (Array.isArray(ids)) out.set(focusId, ids.filter((x): x is string => typeof x === "string"));
}
return out;
}
/** Synchronously persist the selection for one (folder, focus). An empty
* array clears it (so deselect-then-reload doesn't resurrect stale ids). */
saveLastSelection(folder: string, focusId: string, ids: string[]): void {
try {
const all = this.readLastSelectionFile();
if (!all[folder]) all[folder] = {};
if (ids.length) all[folder][focusId] = ids;
else delete all[folder][focusId];
window.localStorage.setItem(this.LAST_SELECTION_LS_KEY, JSON.stringify(all));
} catch (e) {
console.warn("[Stashpad] failed to save last-selection", 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

@ -1,6 +1,7 @@
import { App, Modal, Platform, moment, Notice, setIcon } from "obsidian";
import { App, Modal, Platform, moment, Notice, setIcon, type SecretStorage } from "obsidian";
import { buildTimePickerInto } from "./time-picker";
import { siftMatch } from "./types";
import { generatePassphrase, estimatePasswordStrength } from "./passphrase";
import { newId } from "./id-service";
import type { ImportLogEntry } from "./import-log";
@ -552,7 +553,11 @@ export class ExportStashModal extends Modal {
app: App,
private defaultBaseName: string,
private noteCount: number,
private onConfirm: (baseName: string, password: string | null) => void,
private onConfirm: (baseName: string, password: string | null, remember: boolean) => void,
/** Probe (cached) for whether Argon2id can run here, so the modal can state
* up front whether this export will use the strong suite or the fallback.
* Optional so callers/tests can omit it (the text just stays generic). */
private kdfProbe?: () => Promise<boolean>,
) {
super(app);
}
@ -586,17 +591,103 @@ export class ExportStashModal extends Modal {
const pwArea = encWrap.createDiv({ cls: "stashpad-export-pw-area" });
pwArea.style.display = "none";
const pw1 = pwArea.createEl("input", { type: "password" }) as HTMLInputElement;
pw1.addClass("stashpad-export-name"); pw1.placeholder = "Password";
const pw2 = pwArea.createEl("input", { type: "password" }) as HTMLInputElement;
pw2.addClass("stashpad-export-name"); pw2.placeholder = "Confirm password";
// 0.85.7: each field gets an inline button on its right; the passphrase
// stays hidden by default (Show reveals it). 0.85.8: the button is
// **Paste** while the field is empty (one-click drop-in from a password
// manager) and flips to **Copy** once it has a value — clearing the field
// flips it back. Copy works while masked.
const pwSyncers: Array<() => void> = [];
const makePwRow = (placeholder: string): HTMLInputElement => {
const row = pwArea.createDiv({ cls: "stashpad-export-pw-row" });
const inp = row.createEl("input", { type: "password" }) as HTMLInputElement;
inp.addClass("stashpad-export-name"); inp.placeholder = placeholder;
const btn = row.createEl("button", { cls: "stashpad-export-copy" });
const syncBtn = () => {
const empty = inp.value.length === 0;
btn.setText(empty ? "Paste" : "Copy");
btn.toggleClass("is-paste", empty);
btn.setAttr("aria-label", `${empty ? "Paste into" : "Copy"} ${placeholder.toLowerCase()}`);
};
btn.onclick = async (e) => {
e.preventDefault();
if (inp.value.length === 0) {
try {
const txt = (await navigator.clipboard?.readText())?.trim();
if (!txt) { new Notice("Clipboard is empty."); return; }
inp.value = txt;
inp.dispatchEvent(new Event("input")); // → refresh (validation, meter, button sync)
new Notice("Pasted from clipboard.");
} catch { new Notice("Couldn't read the clipboard."); }
} else {
void navigator.clipboard?.writeText(inp.value).then(
() => new Notice("Passphrase copied to clipboard."),
() => new Notice("Couldn't access the clipboard."),
);
}
};
syncBtn();
pwSyncers.push(syncBtn);
return inp;
};
const pw1 = makePwRow("Password");
const pw2 = makePwRow("Confirm password");
// 0.85.4: live strength meter (a nudge, never a gate) + a generate button.
const meter = pwArea.createDiv({ cls: "stashpad-export-strength" });
const meterBar = meter.createDiv({ cls: "stashpad-strength-bar" });
const meterSegs = [0, 1, 2, 3].map(() => meterBar.createDiv({ cls: "stashpad-strength-seg" }));
const meterLabel = meter.createEl("span", { cls: "stashpad-strength-label" });
const genRow = pwArea.createDiv({ cls: "stashpad-export-genrow" });
const genBtn = genRow.createEl("button", { cls: "stashpad-export-gen", text: "Generate strong passphrase" });
const showBtn = genRow.createEl("button", { cls: "stashpad-export-show", text: "Show" });
const hint = pwArea.createEl("div", { cls: "stashpad-export-pw-hint" });
// 0.84.15: name the scheme so it's clear up front; the export toast then
// reports which KDF actually ran (Argon2id, or the weaker PBKDF2 fallback).
pwArea.createEl("div", {
cls: "stashpad-export-pw-suite",
text: "Encryption: Argon2id + AES-256-GCM (strongest). Falls back to PBKDF2 only if Argon2 can't run on this device.",
// 0.84.15: name the scheme so it's clear up front. 0.85.3: probe this device
// and state explicitly which suite WILL be used — the strong default or the
// fallback — rather than describing both abstractly.
const suite = pwArea.createEl("div", { cls: "stashpad-export-pw-suite" });
suite.setText("Encryption: AES-256-GCM. Checking key-derivation suite for this device…");
if (this.kdfProbe) {
void this.kdfProbe().then((argonOk) => {
suite.toggleClass("is-weak", !argonOk);
suite.setText(
argonOk
? "Encryption: Argon2id + AES-256-GCM — the strongest suite (used on this device)."
: "⚠️ Argon2id can't run on this device, so this export will use the weaker PBKDF2 (600k) + AES-256-GCM fallback.",
);
}).catch(() => {
// Probe failed unexpectedly — keep the neutral text, don't over-claim.
suite.setText("Encryption: AES-256-GCM with a password-derived key.");
});
} else {
suite.setText("Encryption: Argon2id + AES-256-GCM (falls back to PBKDF2 if Argon2 can't run here).");
}
// 0.85.4: optional "remember in this vault" — saves the passphrase to
// Obsidian's secret storage (OS keychain) keyed by the export filename, so
// re-importing on THIS device skips the prompt. Only offered when the API
// exists (≥1.11.4); secrets are device-local, so recipients still need the
// passphrase typed/copied.
const secretStorage = (this.app as App & { secretStorage?: SecretStorage }).secretStorage;
const rememberRow = pwArea.createDiv({ cls: "stashpad-export-remember" });
const rememberCb = rememberRow.createEl("input", { type: "checkbox" }) as HTMLInputElement;
rememberCb.id = "stashpad-export-remember-cb";
const rememberLabel = rememberRow.createEl("label", {
text: "Remember in this vault (this device) — skips the prompt when you re-import here.",
});
rememberLabel.htmlFor = rememberCb.id;
// Shown only while "remember" is ticked: make the device-local scope explicit
// so nobody assumes the saved passphrase travels with the file or syncs.
const rememberNote = pwArea.createDiv({ cls: "stashpad-export-remember-note" });
rememberNote.setText(
"Saved only in this device's keychain — it doesn't sync to your other devices and isn't shared with anyone you send this file to. Keep the passphrase somewhere safe if you'll open this export elsewhere.",
);
rememberNote.style.display = "none";
rememberCb.onchange = () => {
rememberNote.style.display = rememberCb.checked ? "" : "none";
};
if (!secretStorage) rememberRow.style.display = "none";
// 0.84.13: encrypted exports get an "-encrypted" tag in the filename so
// secure bundles are identifiable at a glance. The preview reflects it live
@ -616,6 +707,14 @@ export class ExportStashModal extends Modal {
// Gate the Export button on a valid (matching, non-empty) password when
// encryption is enabled. People are warned that a lost password = lost
// export, but the button itself only blocks the typo/empty cases.
const renderStrength = () => {
const s = estimatePasswordStrength(pw1.value);
meter.style.visibility = pw1.value ? "visible" : "hidden";
meterSegs.forEach((seg, i) => seg.toggleClass("is-on", pw1.value !== "" && i <= s.level));
meterBar.dataset.level = String(s.level);
meterLabel.setText(s.label);
};
const refresh = () => {
const enc = cb.checked;
pwArea.style.display = enc ? "" : "none";
@ -625,32 +724,58 @@ export class ExportStashModal extends Modal {
else if (pw1.value !== pw2.value) { hint.setText("Passwords don't match."); hint.addClass("is-error"); ok = false; }
else { hint.setText("⚠️ If you lose this password, the export can't be recovered."); hint.removeClass("is-error"); }
}
renderStrength();
pwSyncers.forEach((fn) => fn()); // Paste↔Copy per field as values change
go.disabled = !ok;
go.toggleClass("is-disabled", !ok);
renderPreview(); // keep the filename preview in sync with the toggle
};
// Reveal/mask both fields together (so a generated passphrase is readable).
let shown = false;
const setShown = (v: boolean) => {
shown = v;
pw1.type = pw2.type = v ? "text" : "password";
showBtn.setText(v ? "Hide" : "Show");
};
showBtn.onclick = (e) => { e.preventDefault(); setShown(!shown); };
// Generate fills both fields but keeps them HIDDEN (0.85.7): the user
// reveals with Show or grabs it with a Copy button. Save it somewhere — you
// need it to open this export and it can't be recovered if lost.
genBtn.onclick = (e) => {
e.preventDefault();
pw1.value = pw2.value = generatePassphrase();
setShown(false);
new Notice("Passphrase generated (hidden) — Show to view, or Copy to save it.");
refresh();
};
cb.onchange = refresh;
pw1.oninput = refresh;
pw2.oninput = refresh;
refresh();
go.onclick = () => this.commit(effectiveBase(), cb.checked ? pw1.value : null);
const deliver = () =>
this.commit(effectiveBase(), cb.checked ? pw1.value : null, cb.checked && rememberCb.checked);
go.onclick = deliver;
// Enter confirms (when not blocked); Esc / click-out cancels (modal
// default). No Mod+Z registration → native input undo handles clears.
this.scope.register([], "Enter", (e) => {
e.preventDefault();
if (!go.disabled) this.commit(effectiveBase(), cb.checked ? pw1.value : null);
if (!go.disabled) deliver();
});
// Focus + select-all so the prefilled name is ready to type over.
requestAnimationFrame(() => { input.focus(); input.select(); });
}
private commit(raw: string, password: string | null): void {
private commit(raw: string, password: string | null, remember: boolean): void {
const base = raw.trim() || this.defaultBaseName;
const pw = password && password.length ? password : null;
this.delivered = true;
this.close();
this.onConfirm(base, password && password.length ? password : null);
this.onConfirm(base, pw, !!pw && remember);
}
onClose(): void {
// No delivery on cancel/Esc/click-out — the export simply doesn't run.

View file

@ -216,6 +216,9 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
const cb = this.pendingCleanups.pop();
try { if (cb) cb(); } catch {}
}
// 0.85.10: notify the caller the picker closed (pick OR dismiss). The
// destination picker uses this to refocus the composer only on dismiss.
try { this.opts.onClose?.(); } catch {}
}
constructor(
@ -228,6 +231,9 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
allowCreate?: boolean;
onPick: (item: PickerItem) => void;
onCreate?: (query: string) => void;
/** 0.85.10: fired from onClose (any close pick or dismiss). The
* destination picker uses it to refocus the composer on dismiss. */
onClose?: () => void;
/** Optional source for cross-folder notes. Resolved lazily when
* the user starts typing local results from `tree` are returned
* first, and this source is queried only after the local set is
@ -253,17 +259,23 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
}
private loadAll(): void {
const rootNode = this.tree.getRoot();
// 0.85.15: label the LOCAL home like the cross-folder entries
// ("Home — <folder>") so searching a folder by name surfaces its home note
// whether you're inside that folder or not — consistent everywhere, and it
// becomes matchable by the folder name (so the home-pin floats it to top).
const folderName = rootNode.file?.parent?.name?.trim() ?? "";
const localHomeTitle = folderName ? `Home — ${folderName}` : "Home";
const walk = (id: string, depth: number): void => {
const node = this.tree.get(id);
if (node?.file && id !== ROOT_ID) {
this.notes.push({ node, title: `${" ".repeat(depth)}${this.titleFn(node)}`, body: "" });
} else if (node?.file && id === ROOT_ID) {
this.notes.push({ node, title: "Home", body: "" });
this.notes.push({ node, title: localHomeTitle, body: "" });
}
for (const c of this.tree.getChildren(id)) walk(c.id, depth + 1);
};
const rootNode = this.tree.getRoot();
if (rootNode.file) this.notes.push({ node: rootNode, title: "Home", body: "" });
if (rootNode.file) this.notes.push({ node: rootNode, title: localHomeTitle, body: "" });
for (const c of this.tree.getChildren(ROOT_ID)) walk(c.id, 1);
// lazy-read bodies in background
@ -502,31 +514,51 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
// crowded by N folder rows on a vault with many Stashpads.
if (this.opts.folderResults) {
const folders = this.opts.folderResults();
// Show the collapsed entry when the query is empty (folder
// browsing) OR matches at least one folder name (same trigger
// condition as before, just collapsed to one row).
const anyMatch = !q || folders.some((folder) => {
const last = folder.split("/").pop() ?? folder;
return matchesAll(`${folder.toLowerCase()} ${last.toLowerCase()}`);
});
if (folders.length > 0 && anyMatch) {
const collapsed: PickerItem = {
id: `__folder_picker__`,
label: `Open folder in a new tab…`,
node: null,
kind: "folder-open",
};
// 0.64.7: filter-active queries push the folder shortcut down
// so real note rows lead the list.
const hasFilter = Object.keys(parsed.filters).length > 0;
if (hasFilter) items.push(collapsed);
else items.unshift(collapsed);
const collapsed: PickerItem = {
id: `__folder_picker__`,
label: `Open folder in a new tab…`,
node: null,
kind: "folder-open",
};
// 0.85.14: empty query → show it at the TOP (folder browsing). Once
// typing, only show it when the query matches a folder name, and place it
// at the BOTTOM so it's never the first result — real note hits lead, and
// the matching folder's home note still pins to the top (0.85.12).
if (folders.length > 0) {
if (!q) {
items.unshift(collapsed);
} else if (folders.some((folder) => {
const last = folder.split("/").pop() ?? folder;
return matchesAll(`${folder.toLowerCase()} ${last.toLowerCase()}`);
})) {
items.push(collapsed);
}
}
}
if (this.opts.allowCreate && q && !items.some((i) => i.label.trim().toLowerCase() === q)) {
items.push({ id: `__create__`, label: `Create new: "${query}"`, node: null, kind: "create" });
}
// 0.85.12: searching a Stashpad folder by name means you want its HOME
// note. Home notes are otherwise low-priority, but once there are ≥3
// characters of search text, any home note whose TITLE matches the query
// (local home label "Home", or a cross-folder "Home — <folder>" whose
// folder name you've typed) is floated to the very top — "no matter what".
// Title-match (not body) keeps it to genuine folder-name hits, and the ≥3
// gate keeps short/empty queries from surfacing every home. Backs every
// modal that uses this picker — find / destination / move / in-parent.
if (parsed.text.join("").length >= 3) {
const isHomeMatch = (i: PickerItem): boolean =>
i.kind === "note"
&& (i.id === ROOT_ID || i.id === `cross:${ROOT_ID}`)
&& matchesAll(i.label.toLowerCase());
const homes = items.filter(isHomeMatch);
if (homes.length > 0) {
const rest = items.filter((i) => !isHomeMatch(i));
items.splice(0, items.length, ...homes, ...rest);
}
}
return items;
}

View file

@ -70,6 +70,10 @@ export class StashpadPanelsView extends ItemView {
* Fixed buckets ("all"/"mine"/"others"/"byme"/"unassigned") or a
* per-person filter encoded as "person:<authorId>" (0.78.3). */
private taskAssignFilter: string = "all";
/** 0.88.1: folder sub-filter for the Tasks panel, AND-combined with the
* status + assignment filters. "all" = every Stashpad folder, else a
* specific folder path. */
private taskFolderFilter: string = "all";
/** 0.73.11: programmatic panel switch. Called by the per-panel
* command-palette entries so the sidebar lands on the right tab
* when invoked from the keyboard. */
@ -184,7 +188,8 @@ export class StashpadPanelsView extends ItemView {
homeRow.createSpan({ cls: "stashpad-pinned-label", text: "Home" });
homeRow.onclick = () => this.openHomeFromPanel();
const pins = this.plugin.settings.pinnedNotes ?? [];
// 0.86.3: pins now come from note frontmatter (synced), ordered by pinnedAt.
const pins = this.plugin.listPinnedNotes();
if (pins.length === 0) {
const empty = list.createDiv({ cls: "stashpad-pinned-empty" });
empty.setText("No pinned notes yet — right-click a note and choose “Pin to sidebar.”");
@ -311,15 +316,24 @@ export class StashpadPanelsView extends ItemView {
}
}
/** Move a pin from one index to another, updating settings + re-rendering. */
/** Move a pin to a new position by rewriting its `pinnedAt` to fall between
* the items it lands between (0.86.3 order is the synced pinnedAt key). */
private async reorderPin(fromIdx: number, toIdx: number): Promise<void> {
const list = (this.plugin.settings.pinnedNotes ?? []).slice();
const list = this.plugin.listPinnedNotes();
if (fromIdx < 0 || fromIdx >= list.length) return;
const [moved] = list.splice(fromIdx, 1);
const adjusted = toIdx > fromIdx ? toIdx - 1 : toIdx;
list.splice(Math.max(0, Math.min(adjusted, list.length)), 0, moved);
this.plugin.settings.pinnedNotes = list;
await this.plugin.saveSettings();
const moved = list[fromIdx];
const without = list.filter((_, i) => i !== fromIdx);
const insertAt = Math.max(0, Math.min(toIdx > fromIdx ? toIdx - 1 : toIdx, without.length));
const prev = without[insertAt - 1];
const next = without[insertAt];
let at: number;
if (!prev && !next) at = Date.now();
else if (!prev) at = next.pinnedAt - 1000;
else if (!next) at = prev.pinnedAt + 1000;
else at = (prev.pinnedAt + next.pinnedAt) / 2;
try {
await this.app.fileManager.processFrontMatter(moved.file, (fm: any) => { fm.pinnedAt = at; });
} catch (e) { console.warn("[Stashpad] pin reorder failed", e); }
this.render();
}
@ -806,10 +820,27 @@ export class StashpadPanelsView extends ItemView {
}
sel.onchange = () => { this.taskAssignFilter = sel.value; this.render(); };
const tasks = allTasks.filter(assignMatches);
// 0.88.1: Folder filter dropdown (AND-combined). Distinct folders across
// all tasks; "all" shows every folder. Reset to "all" if the previously
// selected folder no longer has any tasks.
const folders = [...new Set(allTasks.map((t) => t.folder))].sort((a, b) => a.localeCompare(b));
if (this.taskFolderFilter !== "all" && !folders.includes(this.taskFolderFilter)) this.taskFolderFilter = "all";
const folderBar = list.createDiv({ cls: "stashpad-task-assign-bar" });
folderBar.createSpan({ cls: "stashpad-task-assign-label", text: "Folder" });
const fsel = folderBar.createEl("select", { cls: "stashpad-task-assign-select" });
const allOpt = fsel.createEl("option", { text: "All folders", value: "all" });
if (this.taskFolderFilter === "all") allOpt.selected = true;
for (const f of folders) {
const o = fsel.createEl("option", { text: f.split("/").pop() || f, value: f });
if (this.taskFolderFilter === f) o.selected = true;
}
fsel.onchange = () => { this.taskFolderFilter = fsel.value; this.render(); };
const folderMatches = (t: TaskItem): boolean => this.taskFolderFilter === "all" || t.folder === this.taskFolderFilter;
const tasks = allTasks.filter((t) => assignMatches(t) && folderMatches(t));
if (tasks.length === 0) {
list.createDiv({ cls: "stashpad-tasks-empty" })
.setText(`No tasks match "${labelFor(this.taskAssignFilter)}".`);
.setText(`No tasks match the current filters.`);
return;
}

160
src/passphrase.ts Normal file
View file

@ -0,0 +1,160 @@
/** 0.85.4: passphrase generation + password-strength estimation for the
* encrypted-.stash export flow, plus deterministic secret-storage IDs.
*
* - generatePassphrase(): xkcd-style "correct-horse-battery-staple" N random
* words from a curated list joined by dashes, with a trailing number. Uses
* crypto.getRandomValues with rejection sampling (no modulo bias).
* - estimatePasswordStrength(): a lightweight, dependency-free nudge meter (NOT
* zxcvbn-grade). Pool-size entropy with a repetition penalty and a small
* common-password floor. Good enough to shame "cool bean"; honest enough not
* to call "aaaaaaaa" strong.
* - secretIdForStashName(): deterministic, collision-resistant id (lowercase
* alphanumeric + dashes, 64) so export-time setSecret and import-time
* getSecret agree from the filename alone. */
/** Curated word list for generated passphrases. Short, common, unambiguous
* (no profanity, no easily-confused homophones). ~280 words log2 8.13
* bits/word, so the default 5-word passphrase + 2-digit number 47 bits of
* entropy comfortably "strong" for a file password. */
const WORDS = (
"able acid acorn actor adept agile alarm album alert algae amber amble anchor angle ankle apple " +
"april arbor arc arctic arena armor arrow ascot aspen atlas atom attic auburn audio autumn award " +
"axis bacon badge bagel baker balmy bamboo banjo barge basil basin batch beach beam bean bear " +
"beaver begin belt bench berry birch bison black blade blaze bliss bloom blue board boat bold " +
"bolt bonus boost booth borax bother bottle boulder bounce bovine bowl brave bread breeze brick " +
"bridge brisk broad bronze brook broom brown brush bubble bucket bug bulb bundle burst cabin " +
"cable cacao cactus camel cameo candle canoe canvas canyon cargo carol carrot castle catch cedar " +
"cello chalk charm chart cheese cherry chess chime chirp cider cinder circle citrus clamp clay " +
"clever cliff cloak clock cloud clover coast cobra cocoa comet coral cosmos cotton couch cougar " +
"crane crater cream crest cricket crisp crown crumb crystal cube cumin curio dagger daisy dapper " +
"dawn deck deer delta denim depot desert dial diamond dingo ditch dock dodge dolphin domino donut " +
"draft dragon drift drum dune dusk eagle early earth easel ebony echo eclipse edge eel eagle " +
"elbow elder ember emboss emerald ember emu engine ermine ether ever fable falcon fancy farm " +
"fawn feather felt fennel fern ferry fiber fiddle field fig finch flame flask flax fleet flint " +
"float flock flora flute focus foam forest fossil fox frame frost fudge garden gecko ginger " +
"glacier glade glass glide globe glory glove gnat goat golden gopher gourd grape grass gravel " +
"grove guava gull hammer hamlet harbor hatch hazel heron hickory hippo honey hornet hostel ivory " +
"ivy jade jaguar jasmine jelly jetty jewel jolly jungle juniper kayak kelp kettle kiwi koala " +
"ladle lagoon lamp lantern larch lark lasso lava ledge lemon lentil leopard lever lilac lime " +
"linen lizard llama lobby locket lodge lotus lunar lupine lynx maple marble marsh meadow medal " +
"melon mellow mentor mesa metro mimic mint mirror mocha morsel moss mottle mural muslin nacho " +
"nectar nest noble nomad noodle nougat nutmeg oasis oat ocean ochre olive onyx opal orbit orca " +
"otter oval owl oxide oyster pace paddle palm panda pantry papaya parka parsley pasta pearl " +
"pebble pelican pepper pewter phantom piano picnic pigeon pilot pine pixel plank plaza plum " +
"pocket pollen pond poplar poppy portal potato pottery prairie prism puffin pumice quail quartz " +
"quill quilt quince rabbit radish raft rapid raven reed reef relish ribbon ridge rind ripple " +
"river robin rocket rope rover ruby rudder rust saffron sage salmon sandal sash satin sauce " +
"scout sedge sequin shadow shale shark shell shore shrub silk silo silver siren sketch slate " +
"sleet slope sloth smoke snail sonar sorrel spark sparrow spice spire sprig spruce squid stable " +
"stamp starling steam stem stilt stone stork storm stove straw stream stucco summit sundae " +
"sunset swan sweater syrup tabby talon tango tapir tassel tawny teal tempo thicket thimble " +
"thistle thorn thunder tidal tiger timber toffee token topaz torch totem trail tulip tundra " +
"turtle tusk twig umber unicorn valley vanilla velvet vermil violet viper vista vivid vortex " +
"waffle walnut warbler wasp water weasel wharf wheat whisk willow window winter wisp wombat " +
"wonder woven yam yarn yeast yew yodel yonder zebra zenith zephyr zigzag zinc zircon"
).split(/\s+/).filter(Boolean);
/** Unbiased random integer in [0, n) via rejection sampling. */
function randIndex(n: number): number {
const limit = Math.floor(0x100000000 / n) * n; // largest multiple of n ≤ 2^32
const buf = new Uint32Array(1);
let x: number;
do { crypto.getRandomValues(buf); x = buf[0]; } while (x >= limit);
return x % n;
}
/** xkcd-style passphrase: `words` random words + a trailing 2-digit number,
* dash-separated. Default 5 words 47 bits of entropy. */
export function generatePassphrase(words = 5): string {
const parts: string[] = [];
for (let i = 0; i < words; i++) parts.push(WORDS[randIndex(WORDS.length)]);
parts.push(String(10 + randIndex(90))); // 10..99 — guarantees a digit class
return parts.join("-");
}
/** Approx. entropy (bits) of a generated passphrase, for honest UI labeling. */
export function passphraseBits(words = 5): number {
return words * Math.log2(WORDS.length) + Math.log2(90);
}
// A few of the most-abused passwords — floored so the meter never flatters them.
const COMMON = new Set([
"password", "passw0rd", "password1", "123456", "12345678", "123456789", "qwerty",
"abc123", "letmein", "welcome", "admin", "iloveyou", "monkey", "dragon", "sunshine",
"princess", "football", "baseball", "trustno1", "000000", "111111", "secret",
]);
export interface PasswordStrength {
/** Estimated entropy in bits (capped/penalized — not a precise figure). */
bits: number;
/** 0 = weak, 1 = fair, 2 = good, 3 = strong. */
level: 0 | 1 | 2 | 3;
label: string;
}
/** Dependency-free strength estimate. Pool-size entropy scaled by a repetition
* penalty, with a hard floor for known-common passwords. Tuned to nudge, not
* to gate the export button never blocks on strength. */
export function estimatePasswordStrength(pw: string): PasswordStrength {
if (!pw) return { bits: 0, level: 0, label: "" };
let pool = 0;
if (/[a-z]/.test(pw)) pool += 26;
if (/[A-Z]/.test(pw)) pool += 26;
if (/[0-9]/.test(pw)) pool += 10;
if (/[^a-zA-Z0-9]/.test(pw)) pool += 32;
pool = Math.max(pool, 2);
// Repetition penalty: a password using few distinct characters (e.g. "aaaa")
// gets credited closer to its unique-char count than its raw length.
const uniq = new Set(pw).size;
const effLen = pw.length * Math.min(1, uniq / pw.length + 0.25);
let bits = effLen * Math.log2(pool);
// Word-phrase ceiling: pool-size entropy badly overcredits plain dictionary
// words ("cool bean" looks like ~53 random bits but is really a guessable
// word pair). When the input is purely space/dash/underscore-separated
// alphabetic tokens, cap at ~11 bits/word (a generous human-vocabulary
// assumption). A generated passphrase dodges this because its trailing number
// token isn't alphabetic, so it keeps the full (high) estimate.
const tokens = pw.split(/[\s\-_]+/).filter(Boolean);
if (tokens.length >= 1 && tokens.every((t) => /^[a-zA-Z]+$/.test(t))) {
bits = Math.min(bits, tokens.length * 11);
}
if (COMMON.has(pw.toLowerCase())) bits = Math.min(bits, 12);
const level: PasswordStrength["level"] =
bits < 36 ? 0 : bits < 56 ? 1 : bits < 76 ? 2 : 3;
const label = ["Weak — easily guessed", "Fair", "Good", "Strong"][level];
return { bits, level, label };
}
/** Simple, fast FNV-1a 8 hex chars. Used only to disambiguate truncated
* secret IDs; not security-sensitive. */
function fnv1a(s: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, "0");
}
/** Deterministic Obsidian secretStorage id for a given .stash base name (the
* filename WITHOUT the `.stash` extension). Charset is lowercase alphanumeric +
* dashes, 64 chars (the SecretStorage constraint). Derived purely from the
* name + an FNV hash suffix so export-time set and import-time get agree and
* truncation collisions are vanishingly unlikely. */
export function secretIdForStashName(baseName: string): string {
const prefix = "stashpad-";
const hash = fnv1a(baseName);
const room = 64 - prefix.length - 1 - hash.length; // "-" + hash
const slug = baseName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, Math.max(0, room))
.replace(/-+$/g, "");
return `${prefix}${slug}-${hash}`;
}

View file

@ -59,7 +59,8 @@ export type CommandId =
| "swapWithParent"
| "togglePin"
| "toggleTask" | "setDue"
| "jumpToTop" | "jumpToBottom";
| "jumpToTop" | "jumpToBottom"
| "commandPalette";
/** Per-command bindings: up to two chord strings ("S" or "Mod+Enter").
* When BOTH are set, `preferRight` decides which actually fires. */
@ -122,6 +123,7 @@ export const COMMAND_META: CommandMeta[] = [
{ 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" },
{ id: "jumpToBottom", label: "Jump to bottom of list", desc: "Default: End — move the cursor to the last note in the current list.", defaultPrimary: "End" },
{ id: "commandPalette", label: "Command palette (Stashpad only)", desc: "Default: Mod+K — open a command palette listing only Stashpad's commands, with Sift search.", defaultPrimary: "Mod+K" },
];
export function buildDefaultBindings(): CommandBindingMap {
@ -144,6 +146,9 @@ export interface StashpadSettings {
* honor Obsidian's "Excluded files" (userIgnoreFilters), so exclusions
* are managed in one place. `.edtz` is always excluded regardless. */
inheritObsidianExclusions: boolean;
/** 0.86.2: folder panel fraction of height given to the Pinned section
* (the rest goes to Folders). Set by dragging the divider. 0.150.85. */
folderPanelPinnedFraction: number;
/** 0.81.1: opt-in performance profiling accumulates render/read/write
* timing so the "Dump performance profile" command reports where the
* time goes on a slow vault. Off by default. */
@ -364,6 +369,7 @@ export const DEFAULT_SETTINGS: StashpadSettings = {
exportFolder: "_exports",
autoImport: false,
inheritObsidianExclusions: true,
folderPanelPinnedFraction: 0.5,
enablePerfProfiling: false,
writeRecoveryLinks: true,
useTemplatesFormat: false,

View file

@ -220,9 +220,23 @@ async function decryptV1(envelope: Uint8Array, password: string): Promise<Uint8A
export async function resolveStashBytes(
app: App,
bytes: Uint8Array,
opts: { allowLater?: boolean; onLater?: () => void } = {},
opts: { allowLater?: boolean; onLater?: () => void; secretId?: string } = {},
): Promise<Uint8Array | null> {
if (!isEncryptedStash(bytes)) return bytes;
// 0.85.4: if this export's passphrase was remembered in this vault's secret
// storage (keyed by filename), try it silently before prompting. This goes
// through the SAME decryptStash path — no bypass of the zip-slip / collision
// guards downstream. A stored-but-wrong secret just falls through to prompt.
if (opts.secretId) {
const ss = (app as App & { secretStorage?: { getSecret(id: string): string | null } }).secretStorage;
let stored: string | null = null;
try { stored = ss?.getSecret(opts.secretId) ?? null; } catch { stored = null; }
if (stored) {
try { return await decryptStash(bytes, stored); } catch { /* fall through to prompt */ }
}
}
let errorMsg: string | undefined;
for (;;) {
const r = await new Promise<StashPasswordResult>((resolve) => {

View file

@ -3,6 +3,8 @@ import type { TFile } from "obsidian";
export const STASHPAD_VIEW_TYPE = "stashpad-view";
/** 0.68.0: sidebar panels view (Pinned Notes + future panels). */
export const STASHPAD_PANELS_VIEW_TYPE = "stashpad-panels";
/** 0.86.0: left-sidebar folder picker (pinned notes + folders, split). */
export const STASHPAD_FOLDER_PANEL_VIEW_TYPE = "stashpad-folder-panel";
/** 0.74.1: right-sidebar detail panel. Shows the currently-cursored
* note's body + metadata + children. Lives separately from the
* left-sidebar panels view (Pinned/Shared/Tasks). */
@ -99,6 +101,12 @@ export const RESERVED_FRONTMATTER: readonly string[] = [
// 0.78.1: task scheduling/assignment — Stashpad-managed, so clones /
// templates must not carry someone else's due date or assignees.
"due", "assignedTo", "assignedBy",
// 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.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",
] as const;
/** Reserved Stashpad subfolder names (machine-managed; not user notes).

File diff suppressed because it is too large Load diff

View file

@ -1024,6 +1024,14 @@
}
.stashpad-note-enter { cursor: pointer; color: var(--text-accent); }
.stashpad-note-enter:hover { text-decoration: underline; }
/* 0.87.1: children-count arrow + (mobile) task checkbox on one line */
.stashpad-note-meta-bottom { display: flex; align-items: center; gap: 6px; }
/* mobile: checkbox moves into the meta column, so the row drops the desktop
leading checkbox grid column frees width so the single action button
doesn't wrap. */
.is-mobile .stashpad-note.is-task { grid-template-columns: auto 1fr auto; }
.is-mobile .stashpad-note-meta-bottom .stashpad-note-task-checkbox { margin: 0; padding: 2px; }
.is-mobile .stashpad-note-meta-bottom .stashpad-note-task-checkbox svg { width: 18px; height: 18px; }
.stashpad-note-body { min-width: 0; word-wrap: break-word; }
.stashpad-note-body > :first-child { margin-top: 0; }
@ -2126,6 +2134,15 @@
.stashpad-export-field { display: flex; flex-direction: column; gap: 4px; }
.stashpad-export-label { font-size: var(--font-ui-small); color: var(--text-muted); }
.stashpad-export-name { width: 100%; }
/* 0.85.7: password field + inline Copy button */
.stashpad-export-pw-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.stashpad-export-pw-row .stashpad-export-name { flex: 1; min-width: 0; }
.stashpad-export-copy { flex: 0 0 auto; }
.stashpad-export-preview {
margin-top: 8px;
font-family: var(--font-monospace);
@ -2168,6 +2185,63 @@
color: var(--text-faint);
margin-top: 2px;
}
.stashpad-export-pw-suite.is-weak { color: var(--text-warning); }
/* 0.85.4: password strength meter (nudge), generate/show row, remember toggle */
.stashpad-export-strength {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
visibility: hidden;
}
.stashpad-strength-bar {
display: flex;
gap: 3px;
flex: 0 0 auto;
}
.stashpad-strength-seg {
width: 26px;
height: 5px;
border-radius: 3px;
background: var(--background-modifier-border);
}
/* color the lit segments by the bar's data-level (0 weak … 3 strong) */
.stashpad-strength-bar[data-level="0"] .stashpad-strength-seg.is-on { background: var(--text-error); }
.stashpad-strength-bar[data-level="1"] .stashpad-strength-seg.is-on { background: var(--text-warning); }
.stashpad-strength-bar[data-level="2"] .stashpad-strength-seg.is-on { background: var(--color-yellow); }
.stashpad-strength-bar[data-level="3"] .stashpad-strength-seg.is-on { background: var(--text-success); }
.stashpad-strength-label {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.stashpad-export-genrow {
display: flex;
gap: 8px;
margin-top: 8px;
}
.stashpad-export-show { flex: 0 0 auto; }
.stashpad-export-remember {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
}
.stashpad-export-remember label {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
line-height: 1.3;
}
.stashpad-export-remember-note {
margin-top: 4px;
padding: 6px 8px;
border-left: 2px solid var(--text-warning);
background: var(--background-modifier-hover);
border-radius: 4px;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
line-height: 1.35;
}
.stashpad-export-error {
color: var(--text-error);
font-size: var(--font-ui-small);
@ -3998,3 +4072,148 @@
font-size: var(--font-ui-smaller);
text-align: center;
}
/* ============================================================
Folder panel (0.86.0) left-sidebar folder picker:
pinned notes (top) + Stashpad folders (bottom), each scrollable.
============================================================ */
.stashpad-folderpanel-root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 0;
}
.stashpad-folderpanel-section {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 50%; /* split the panel in half */
}
/* draggable divider between Pinned and Folders */
.stashpad-folderpanel-divider {
flex: 0 0 auto;
height: 9px;
display: flex;
align-items: center;
justify-content: center;
cursor: row-resize;
border-top: 1px solid var(--background-modifier-border);
touch-action: none; /* let pointer drag work on touch */
}
.stashpad-folderpanel-divider:hover { background: var(--background-modifier-hover); }
.stashpad-folderpanel-divider-grip {
width: 28px;
height: 3px;
border-radius: 2px;
background: var(--background-modifier-border);
}
.stashpad-folderpanel-divider:hover .stashpad-folderpanel-divider-grip { background: var(--text-faint); }
body.stashpad-folderpanel-resizing { cursor: row-resize; user-select: none; }
.is-mobile .stashpad-folderpanel-divider { height: 16px; } /* easier to grab */
.stashpad-folderpanel-heading {
flex: 0 0 auto;
padding: 8px 10px 4px;
font-size: var(--font-ui-smaller);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-faint);
}
.stashpad-folderpanel-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 0 6px 8px;
}
.stashpad-folderpanel-row {
display: flex;
align-items: center;
gap: 7px;
width: 100%;
padding: 2px 8px;
min-height: 0;
line-height: 1.3;
border-radius: 5px;
background: transparent;
border: none;
box-shadow: none;
text-align: left;
cursor: pointer;
color: var(--text-normal);
font-size: var(--font-ui-small);
}
.stashpad-folderpanel-row:hover { background: var(--background-modifier-hover); }
.stashpad-folderpanel-row-icon { display: inline-flex; flex: 0 0 auto; color: var(--text-muted); }
.stashpad-folderpanel-row-icon svg { width: 15px; height: 15px; }
.stashpad-folderpanel-row-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stashpad-folderpanel-row-sub {
flex: 0 0 auto;
color: var(--text-faint);
font-size: var(--font-ui-smaller);
max-width: 38%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* open indicator dot */
.stashpad-folderpanel-dot {
flex: 0 0 auto;
width: 7px;
height: 7px;
border-radius: 50%;
background: transparent;
border: 1px solid var(--background-modifier-border);
}
.stashpad-folderpanel-folder-row.is-open .stashpad-folderpanel-dot {
background: var(--interactive-accent);
border-color: var(--interactive-accent);
}
.stashpad-folderpanel-folder-row.is-open .stashpad-folderpanel-row-label { font-weight: var(--font-medium); }
/* per-row action buttons (reveal / open in new tab) */
.stashpad-folderpanel-actions {
flex: 0 0 auto;
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 80ms ease;
}
.stashpad-folderpanel-folder-row:hover .stashpad-folderpanel-actions { opacity: 1; }
.stashpad-folderpanel-iconbtn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
height: 22px;
width: 22px;
background: transparent;
border: none;
box-shadow: none;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
}
.stashpad-folderpanel-iconbtn:hover { background: var(--background-modifier-hover); color: var(--text-normal); }
.stashpad-folderpanel-iconbtn svg { width: 15px; height: 15px; }
.stashpad-folderpanel-empty {
padding: 8px 10px;
color: var(--text-faint);
font-size: var(--font-ui-smaller);
}
/* rename modal */
.stashpad-folderpanel-rename-input { width: 100%; margin-bottom: 12px; }
.stashpad-folderpanel-rename-footer { display: flex; justify-content: flex-end; gap: 8px; }
/* mobile: a touch more height for tap targets, always-visible row actions */
.is-mobile .stashpad-folderpanel-row { padding: 5px 10px; }
.is-mobile .stashpad-folderpanel-actions { opacity: 1; }
.is-mobile .stashpad-folderpanel-iconbtn { height: 28px; width: 28px; }
/* 0.88.1: by-author select inside the View popover */
.stashpad-view-author-select { margin-left: 8px; max-width: 160px; font-size: var(--font-ui-smaller); }