mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.91.2: folder panel, Stashpad command palette, mobile + import polish
This commit is contained in:
parent
634f0d6a46
commit
ab781538a1
20 changed files with 2181 additions and 240 deletions
143
main.js
143
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
126
release-notes/0.91.2.md
Normal 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.11–0.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.4–0.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.0–0.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
37
scripts/cdp.mjs
Normal 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
37
scripts/obs
Executable 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
62
src/command-palette.ts
Normal 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 id→method 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;
|
||||
}
|
||||
|
|
@ -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
301
src/folder-panel-view.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
395
src/main.ts
395
src/main.ts
|
|
@ -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. */
|
||||
|
|
|
|||
155
src/modals.ts
155
src/modals.ts
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
160
src/passphrase.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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.15–0.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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
586
src/view.ts
586
src/view.ts
File diff suppressed because it is too large
Load diff
219
styles.css
219
styles.css
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue