mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
749 lines
38 KiB
TypeScript
749 lines
38 KiB
TypeScript
import { App, ItemView, Menu, Modal, Notice, Platform, TFile, TFolder, WorkspaceLeaf, setIcon } from "obsidian";
|
|
import type StashpadPlugin from "./main";
|
|
import { ROOT_ID, STASHPAD_FOLDER_PANEL_VIEW_TYPE, STASHPAD_VIEW_TYPE, type StashpadId } from "./types";
|
|
import { renderCountBadge } from "./panels-view";
|
|
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()));
|
|
}
|
|
|
|
/** Public re-render hook — e.g. after settings change a folder's archive flag,
|
|
* so the archive icon repaints without waiting for a vault event. */
|
|
refresh(): void { 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)}%`;
|
|
const pinHeading = pinnedSection.createDiv({ cls: "stashpad-folderpanel-heading stashpad-folderpanel-heading-row" });
|
|
pinHeading.createSpan({ cls: "stashpad-folderpanel-heading-title", text: "Pinned" });
|
|
const optsBtn = pinHeading.createEl("button", { cls: "stashpad-folderpanel-iconbtn" });
|
|
setIcon(optsBtn, "list");
|
|
optsBtn.setAttr("aria-label", "Pinned view options");
|
|
optsBtn.onclick = (e) => { e.stopPropagation(); this.openPinnedOptionsMenu(e); };
|
|
this.renderPinned(pinnedSection.createDiv({ cls: "stashpad-folderpanel-list stashpad-folderpanel-pins" }));
|
|
|
|
// --- 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.setCssStyles({ flex: "1 1 0" });
|
|
// 0.98.37: the "Folders" heading is itself the folder-switcher button, with a
|
|
// dedicated encrypted-trash button beside it.
|
|
const head = folderSection.createDiv({ cls: "stashpad-folderpanel-heading stashpad-folderpanel-heading-row" });
|
|
const he:HTMLElement = head.createSpan({ cls: "stashpad-folderpanel-heading-title stashpad-folderpanel-heading-switch", text: "Folders" });
|
|
he.setAttr("aria-label", "Open folder switcher");
|
|
he.onmousedown = (e) => { if (e.button === 0) { e.preventDefault(); this.plugin.openFolderPicker(); } };
|
|
const newBtn = head.createEl("button", { cls: "stashpad-folderpanel-iconbtn stashpad-folderpanel-heading-newfolder" });
|
|
setIcon(newBtn, "folder-plus");
|
|
newBtn.setAttr("aria-label", "New Stashpad folder (folder switcher)");
|
|
// 0.112.3: open the folder switcher (which already has a "Create" entry)
|
|
// rather than a bespoke prompt — one place to create + switch folders.
|
|
newBtn.onmousedown = (e) => { if (e.button === 0) { e.preventDefault(); e.stopPropagation(); this.plugin.openFolderPicker(); } };
|
|
if (this.plugin.encryption?.isConfigured?.()) {
|
|
const trashBtn = head.createEl("button", { cls: "stashpad-folderpanel-iconbtn stashpad-folderpanel-heading-trash" });
|
|
setIcon(trashBtn, "trash-2");
|
|
trashBtn.setAttr("aria-label", "Open encrypted trash");
|
|
trashBtn.onmousedown = (e) => { if (e.button === 0) { e.preventDefault(); e.stopPropagation(); this.plugin.openEncryptedTrash(); } };
|
|
}
|
|
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)}%`;
|
|
};
|
|
// 0.140.3 (review): tear the document listeners down on pointerup AND
|
|
// pointercancel (mobile scroll/palm-rejection ends a gesture with cancel,
|
|
// not up), and expose the cleanup so onClose can run it if the view is
|
|
// detached mid-drag — the old code leaked the document listeners + left the
|
|
// resizing cursor class stuck when either happened.
|
|
const cleanup = () => {
|
|
document.removeEventListener("pointermove", onMove);
|
|
document.removeEventListener("pointerup", onUp);
|
|
document.removeEventListener("pointercancel", onUp);
|
|
document.body.removeClass("stashpad-folderpanel-resizing");
|
|
this.dividerCleanup = null;
|
|
};
|
|
const onUp = (ev: PointerEvent) => {
|
|
try { divider.releasePointerCapture(ev.pointerId); } catch { /* noop */ }
|
|
if (ev.type === "pointerup" && pending != null) {
|
|
this.plugin.settings.folderPanelPinnedFraction = pending;
|
|
void this.plugin.saveSettings();
|
|
}
|
|
cleanup();
|
|
};
|
|
this.registerDomEvent(divider, "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);
|
|
document.addEventListener("pointercancel", onUp);
|
|
this.dividerCleanup = cleanup;
|
|
});
|
|
}
|
|
|
|
/** 0.140.3: run any in-flight divider-drag cleanup (see attachDividerDrag). */
|
|
private dividerCleanup: (() => void) | null = null;
|
|
|
|
// ---------- pinned notes (top) ----------
|
|
|
|
/** Pin subtree expansion state (key = `folder|id`), kept across re-renders. */
|
|
private pinExpanded = new Set<string>();
|
|
|
|
/** 0.105.1: which folder rows are expanded to reveal their Home list-pinned
|
|
* notes (key = folder path), kept across re-renders. */
|
|
private folderPinExpanded = new Set<string>();
|
|
|
|
private openPinnedOptionsMenu(e: MouseEvent): void {
|
|
const cur = this.plugin.settings.folderPanelPinnedGrouping ?? "pin-order";
|
|
const menu = new Menu();
|
|
menu.addItem((i) => i.setTitle("Sort by pin order").setChecked(cur === "pin-order")
|
|
.onClick(() => void this.setPinnedGrouping("pin-order")));
|
|
menu.addItem((i) => i.setTitle("Group by folder").setChecked(cur === "folder")
|
|
.onClick(() => void this.setPinnedGrouping("folder")));
|
|
menu.showAtMouseEvent(e);
|
|
}
|
|
|
|
private async setPinnedGrouping(mode: "pin-order" | "folder"): Promise<void> {
|
|
if ((this.plugin.settings.folderPanelPinnedGrouping ?? "pin-order") === mode) return;
|
|
this.plugin.settings.folderPanelPinnedGrouping = mode;
|
|
await this.plugin.saveSettings();
|
|
this.render();
|
|
}
|
|
|
|
private renderPinned(list: HTMLElement): void {
|
|
// 0.102.x: pinned FOLDERS mix into the top "Pinned" section (above the pinned
|
|
// notes), using the same folder rows as the list below. The bottom Folders
|
|
// subsection is unchanged — pinned folders still appear there ranked-first.
|
|
const pinnedFolders = this.plugin.discoverStashpadFolders().filter((f) => this.folderState(f) === "pinned");
|
|
const pins = this.plugin.listPinnedNotes();
|
|
if (pinnedFolders.length === 0 && pins.length === 0) {
|
|
list.createDiv({ cls: "stashpad-folderpanel-empty", text: "Nothing pinned yet — pin a note or folder from its right-click menu." });
|
|
return;
|
|
}
|
|
if (pinnedFolders.length > 0) {
|
|
const open = this.openFolders();
|
|
for (const folder of pinnedFolders) this.renderFolderRow(list, folder, open);
|
|
}
|
|
if (pins.length === 0) return;
|
|
const grouping = this.plugin.settings.folderPanelPinnedGrouping ?? "pin-order";
|
|
if (grouping === "folder") {
|
|
// Group by Stashpad, MRU folder floated to the top (mirrors the Pinned panel).
|
|
const groups = new Map<string, Array<{ folder: string; id: string; file: TFile; idx: number }>>();
|
|
pins.forEach((pin, idx) => {
|
|
let bucket = groups.get(pin.folder);
|
|
if (!bucket) { bucket = []; groups.set(pin.folder, bucket); }
|
|
bucket.push({ ...pin, idx });
|
|
});
|
|
const mru = (this.plugin.lastActiveStashpadLeaf?.view as any)?.noteFolder as string | undefined;
|
|
const order = Array.from(groups.keys());
|
|
if (mru && groups.has(mru)) { order.splice(order.indexOf(mru), 1); order.unshift(mru); }
|
|
for (const folder of order) {
|
|
const header = list.createDiv({ cls: "stashpad-pinned-group-header" });
|
|
if (folder === mru) header.addClass("is-active-folder");
|
|
header.createSpan({ cls: "stashpad-pinned-group-name", text: folder.split("/").pop() || folder });
|
|
for (const p of groups.get(folder) ?? []) this.renderPinNote(list, p.folder, p.id, p.file, p.idx);
|
|
}
|
|
} else {
|
|
pins.forEach((pin, idx) => this.renderPinNote(list, pin.folder, pin.id, pin.file, idx));
|
|
}
|
|
}
|
|
|
|
/** Drag-reorder a pin by rewriting its `pinnedAt` to fall between its new
|
|
* neighbors (the synced ordering key). Mirrors the Pinned panel. */
|
|
private async reorderPin(fromIdx: number, toIdx: number): Promise<void> {
|
|
const list = this.plugin.listPinnedNotes();
|
|
if (fromIdx < 0 || fromIdx >= list.length) return;
|
|
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();
|
|
}
|
|
|
|
/** One top-level pinned note: color tint, completed style, and an expandable
|
|
* child-count badge (borrowed from the Pinned panel). Reuses the
|
|
* `.stashpad-pinned-*` classes so styling + note colors stay identical. */
|
|
private renderPinNote(list: HTMLElement, folder: string, id: string, file: TFile, idx: number): void {
|
|
const fm = (this.app.metadataCache.getFileCache(file)?.frontmatter ?? {}) as any;
|
|
const color = typeof fm.color === "string" ? fm.color : null;
|
|
const completed = fm.completed === true;
|
|
const children = this.childrenOf(folder, id);
|
|
const hasChildren = children.length > 0;
|
|
const key = `${folder}|${id}`;
|
|
const isExpanded = this.pinExpanded.has(key);
|
|
|
|
const row = list.createDiv({ cls: "stashpad-pinned-row" });
|
|
if (color) { row.addClass("has-color"); row.style.setProperty("--stashpad-note-color", color); }
|
|
if (completed) row.addClass("is-completed");
|
|
|
|
// HTML5 drag-reorder (mirrors the Pinned panel). pinnedAt is the synced key.
|
|
row.draggable = true;
|
|
row.dataset.pinIdx = String(idx);
|
|
row.addEventListener("dragstart", (e) => {
|
|
e.dataTransfer?.setData("text/plain", String(idx));
|
|
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
|
|
row.addClass("is-dragging");
|
|
});
|
|
row.addEventListener("dragend", () => row.removeClass("is-dragging"));
|
|
row.addEventListener("dragover", (e) => {
|
|
e.preventDefault();
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
const rect = row.getBoundingClientRect();
|
|
const before = (e.clientY - rect.top) < rect.height / 2;
|
|
row.toggleClass("drop-before", before);
|
|
row.toggleClass("drop-after", !before);
|
|
});
|
|
row.addEventListener("dragleave", () => { row.removeClass("drop-before"); row.removeClass("drop-after"); });
|
|
row.addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
row.removeClass("drop-before"); row.removeClass("drop-after");
|
|
const fromIdx = parseInt(e.dataTransfer?.getData("text/plain") ?? "", 10);
|
|
if (!Number.isFinite(fromIdx) || fromIdx === idx) return;
|
|
const rect = row.getBoundingClientRect();
|
|
const before = (e.clientY - rect.top) < rect.height / 2;
|
|
void this.reorderPin(fromIdx, before ? idx : idx + 1);
|
|
});
|
|
|
|
const toggle = row.createSpan({ cls: "stashpad-pinned-toggle" });
|
|
if (hasChildren) {
|
|
renderCountBadge(toggle, children.length, isExpanded);
|
|
toggle.onclick = (e) => {
|
|
e.stopPropagation();
|
|
if (this.pinExpanded.has(key)) this.pinExpanded.delete(key);
|
|
else this.pinExpanded.add(key);
|
|
this.render();
|
|
};
|
|
}
|
|
const icon = row.createSpan({ cls: "stashpad-pinned-icon" });
|
|
setIcon(icon, hasChildren ? "folder-tree" : "file-text");
|
|
if (color) icon.style.color = color;
|
|
const label = row.createSpan({ cls: "stashpad-pinned-label", text: this.titleFromFile(file) });
|
|
label.onclick = () => { this.onNavigateAway(); void this.plugin.revealNoteInStashpad(file); };
|
|
// Folder subtitle is hidden by CSS (.stashpad-panel-pinned) but kept for the
|
|
// group-by-folder mode where headers already supply context.
|
|
row.createSpan({ cls: "stashpad-pinned-folder", text: folder.split("/").pop() || folder });
|
|
row.oncontextmenu = (e) => {
|
|
e.preventDefault();
|
|
const menu = new Menu();
|
|
menu.addItem((it) => it.setTitle("Unpin from sidebar").setIcon("pin-off")
|
|
.onClick(() => void this.plugin.unpinNote({ folder, id })));
|
|
menu.showAtMouseEvent(e);
|
|
};
|
|
|
|
if (hasChildren && isExpanded) {
|
|
const box = list.createDiv({ cls: "stashpad-pinned-children" });
|
|
this.renderPinSubtree(box, folder, id, 1);
|
|
}
|
|
}
|
|
|
|
private renderPinSubtree(parent: HTMLElement, folder: string, parentId: StashpadId, depth: number): void {
|
|
for (const child of this.childrenOf(folder, parentId)) {
|
|
const fm = (this.app.metadataCache.getFileCache(child)?.frontmatter ?? {}) as any;
|
|
const childId = typeof fm.id === "string" ? fm.id : null;
|
|
if (!childId) continue;
|
|
const color = typeof fm.color === "string" ? fm.color : null;
|
|
const completed = fm.completed === true;
|
|
const grandkids = this.childrenOf(folder, childId);
|
|
const hasGrandkids = grandkids.length > 0;
|
|
const key = `${folder}|${childId}`;
|
|
const isExpanded = this.pinExpanded.has(key);
|
|
const row = parent.createDiv({ cls: "stashpad-pinned-subrow" });
|
|
if (completed) row.addClass("is-completed");
|
|
row.style.paddingLeft = `${depth * 16}px`;
|
|
const toggle = row.createSpan({ cls: "stashpad-pinned-toggle" });
|
|
if (hasGrandkids) {
|
|
renderCountBadge(toggle, grandkids.length, isExpanded);
|
|
toggle.onclick = (e) => {
|
|
e.stopPropagation();
|
|
if (this.pinExpanded.has(key)) this.pinExpanded.delete(key);
|
|
else this.pinExpanded.add(key);
|
|
this.render();
|
|
};
|
|
}
|
|
const icon = row.createSpan({ cls: "stashpad-pinned-icon" });
|
|
setIcon(icon, "file-text");
|
|
if (color) icon.style.color = color;
|
|
const label = row.createSpan({ cls: "stashpad-pinned-label", text: this.titleFromFile(child) });
|
|
label.onclick = () => { this.onNavigateAway(); void this.plugin.revealNoteInStashpad(child); };
|
|
if (hasGrandkids && isExpanded) this.renderPinSubtree(parent, folder, childId, depth + 1);
|
|
}
|
|
}
|
|
|
|
/** Children of an id within a folder (frontmatter.parent matches), created-asc. */
|
|
private childrenOf(folder: string, parentId: StashpadId): TFile[] {
|
|
const out: TFile[] = [];
|
|
for (const f of this.app.vault.getMarkdownFiles()) {
|
|
const dir = f.parent?.path?.replace(/\/+$/, "") ?? "";
|
|
if (dir !== folder) continue;
|
|
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter as any;
|
|
if (!fm || typeof fm.id !== "string") continue;
|
|
const p = fm.parent;
|
|
if (p === parentId || (parentId === ROOT_ID && (p == null || p === ROOT_ID))) {
|
|
if (fm.id === ROOT_ID) continue;
|
|
out.push(f);
|
|
}
|
|
}
|
|
out.sort((a, b) => {
|
|
const ca = (this.app.metadataCache.getFileCache(a)?.frontmatter as any)?.created ?? "";
|
|
const cb = (this.app.metadataCache.getFileCache(b)?.frontmatter as any)?.created ?? "";
|
|
return String(ca).localeCompare(String(cb));
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/** 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. 0.95.0: also counts DEFERRED tabs. After a reload Obsidian
|
|
* lazy-loads inactive leaves — their `view` is a placeholder with no
|
|
* `noteFolder`, so the old live-view-only check lit up just the focused tab.
|
|
* The folder survives in the leaf's persisted view state (`folderOverride`,
|
|
* empty/null = the default folder), so read that when the live view isn't
|
|
* resolved yet. No caching needed — it's exact even right after reload. */
|
|
private openFolders(): Set<string> {
|
|
const set = new Set<string>();
|
|
const fallback = (this.plugin.settings.folder || "Stashpad").replace(/\/+$/, "");
|
|
for (const leaf of this.app.workspace.getLeavesOfType(STASHPAD_VIEW_TYPE)) {
|
|
let f = ((leaf.view as any)?.noteFolder ?? "").replace(/\/+$/, "");
|
|
if (!f) {
|
|
const st = ((leaf.getViewState?.() as any)?.state ?? {}) as { folderOverride?: string | null };
|
|
f = (((st.folderOverride ?? "") || fallback)).replace(/\/+$/, "");
|
|
}
|
|
if (f) set.add(f);
|
|
}
|
|
return set;
|
|
}
|
|
|
|
// ---------- per-folder placement (pin / downrank / hide) ----------
|
|
|
|
private static clean(folder: string): string { return folder.replace(/\/+$/, ""); }
|
|
|
|
/** Current placement of a folder. "normal" = in none of the override lists. */
|
|
private folderState(folder: string): "pinned" | "downranked" | "hidden" | "normal" {
|
|
const c = StashpadFolderPanelView.clean(folder);
|
|
const s = this.plugin.settings;
|
|
if ((s.folderPanelPinned ?? []).includes(c)) return "pinned";
|
|
if ((s.folderPanelDownranked ?? []).includes(c)) return "downranked";
|
|
if ((s.folderPanelHidden ?? []).includes(c)) return "hidden";
|
|
return "normal";
|
|
}
|
|
|
|
/** Move a folder to a placement, clearing it from the other two lists first.
|
|
* "normal" just removes it everywhere. Persists + re-renders. */
|
|
private async setFolderState(folder: string, state: "pinned" | "downranked" | "hidden" | "normal"): Promise<void> {
|
|
const c = StashpadFolderPanelView.clean(folder);
|
|
const s = this.plugin.settings;
|
|
s.folderPanelPinned = (s.folderPanelPinned ?? []).filter((f) => f !== c);
|
|
s.folderPanelDownranked = (s.folderPanelDownranked ?? []).filter((f) => f !== c);
|
|
s.folderPanelHidden = (s.folderPanelHidden ?? []).filter((f) => f !== c);
|
|
if (state === "pinned") s.folderPanelPinned.push(c);
|
|
else if (state === "downranked") s.folderPanelDownranked.push(c);
|
|
else if (state === "hidden") s.folderPanelHidden.push(c);
|
|
await this.plugin.saveSettings();
|
|
this.render();
|
|
}
|
|
|
|
/** folder path → its Home note's color (for the folder-row icon tint).
|
|
* Rebuilt once per renderFolders in a single vault pass. */
|
|
private homeColorByFolder = new Map<string, string>();
|
|
private folderHomeColor(folder: string): string | null {
|
|
return this.homeColorByFolder.get(StashpadFolderPanelView.clean(folder)) ?? null;
|
|
}
|
|
private rebuildHomeColors(): void {
|
|
this.homeColorByFolder.clear();
|
|
for (const f of this.app.vault.getMarkdownFiles()) {
|
|
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter as any;
|
|
if (!fm || fm.id !== ROOT_ID || typeof fm.color !== "string" || !fm.color.trim()) continue;
|
|
const dir = (f.parent?.path ?? "").replace(/\/+$/, "");
|
|
if (dir) this.homeColorByFolder.set(dir, fm.color);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
this.rebuildHomeColors();
|
|
const open = this.openFolders();
|
|
|
|
// Partition by placement (discoverStashpadFolders is already alpha-sorted, so
|
|
// each group preserves alphabetical order). Hidden folders drop out of the
|
|
// main list and surface in the collapsible "Hidden" section below.
|
|
const pinned: string[] = [], normal: string[] = [], downranked: string[] = [], hidden: string[] = [];
|
|
for (const folder of folders) {
|
|
switch (this.folderState(folder)) {
|
|
case "pinned": pinned.push(folder); break;
|
|
case "downranked": downranked.push(folder); break;
|
|
case "hidden": hidden.push(folder); break;
|
|
default: normal.push(folder);
|
|
}
|
|
}
|
|
|
|
const ordered = [...pinned, ...normal, ...downranked];
|
|
if (ordered.length === 0 && hidden.length === 0) {
|
|
list.createDiv({ cls: "stashpad-folderpanel-empty", text: "No Stashpad folders yet." });
|
|
return;
|
|
}
|
|
for (const folder of ordered) this.renderFolderRow(list, folder, open);
|
|
|
|
if (hidden.length > 0) this.renderHiddenSection(list, hidden);
|
|
}
|
|
|
|
private renderFolderRow(list: HTMLElement, folder: string, open: Set<string>): void {
|
|
const state = this.folderState(folder);
|
|
const isOpen = open.has(StashpadFolderPanelView.clean(folder));
|
|
const row = list.createDiv({ cls: "stashpad-folderpanel-row stashpad-folderpanel-folder-row" });
|
|
if (isOpen) row.addClass("is-open");
|
|
if (state === "downranked") row.addClass("is-downranked");
|
|
if (state === "pinned") row.addClass("is-pinned");
|
|
|
|
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");
|
|
|
|
if (state === "pinned") {
|
|
const pin = row.createSpan({ cls: "stashpad-folderpanel-pinmark" });
|
|
setIcon(pin, "pin");
|
|
pin.setAttr("aria-label", "Pinned");
|
|
}
|
|
|
|
// 0.105.1: reveal this folder's Home list-pinned notes inline. The chevron
|
|
// shows only when the folder has any; clicking toggles a sub-list below the
|
|
// row. stopPropagation so it doesn't trigger the row's folder navigation.
|
|
const homePinned = this.folderHomePinnedNotes(folder);
|
|
const pinKey = StashpadFolderPanelView.clean(folder);
|
|
const pinsExpanded = this.folderPinExpanded.has(pinKey);
|
|
if (homePinned.length > 0) {
|
|
const chev = row.createSpan({ cls: "stashpad-folderpanel-pinreveal" });
|
|
renderCountBadge(chev, homePinned.length, pinsExpanded);
|
|
chev.setAttr("aria-label", pinsExpanded ? "Hide pinned notes" : `${homePinned.length} pinned note${homePinned.length === 1 ? "" : "s"}`);
|
|
chev.addEventListener("mousedown", (e) => { e.stopPropagation(); e.preventDefault(); });
|
|
chev.onclick = (e) => {
|
|
e.stopPropagation();
|
|
if (this.folderPinExpanded.has(pinKey)) this.folderPinExpanded.delete(pinKey);
|
|
else this.folderPinExpanded.add(pinKey);
|
|
this.render();
|
|
};
|
|
}
|
|
|
|
// 0.95.1: per-folder icon. Tinted by the folder's Home-note color when set.
|
|
// 0.98.37: archive folders show the ARCHIVE icon in place of the folder icon
|
|
// (one icon instead of folder + a separate badge).
|
|
const isArchive = this.plugin.isArchiveFolder(folder);
|
|
const folderIcon = row.createSpan({ cls: "stashpad-folderpanel-folder-icon" });
|
|
// 0.118.0: a user-set per-folder icon wins; otherwise archive folders show
|
|
// the archive glyph and everything else the generic folder icon.
|
|
setIcon(folderIcon, this.plugin.getFolderIcon(folder) ?? (isArchive ? "archive" : "folder"));
|
|
// 0.134.4 (B3): archives are plaintext by default now — don't claim auto-encryption.
|
|
if (isArchive) folderIcon.setAttr("aria-label", "Archive folder — de-indexed from search");
|
|
const homeColor = this.folderHomeColor(folder);
|
|
if (homeColor) folderIcon.style.color = homeColor;
|
|
|
|
const name = folder.split("/").pop() || folder;
|
|
row.createSpan({ cls: "stashpad-folderpanel-row-label", text: name });
|
|
|
|
// 0.98.37: reveal-in-file-explorer moved to the context menu only; keep the
|
|
// open-in-new-tab quick button.
|
|
const actions = row.createDiv({ cls: "stashpad-folderpanel-actions" });
|
|
const newTabBtn = actions.createEl("button", { cls: "stashpad-folderpanel-iconbtn" });
|
|
setIcon(newTabBtn, "plus-square");
|
|
newTabBtn.setAttr("aria-label", "Open in new tab");
|
|
// 0.98.38: fire on mousedown, not click. Opening a tab moves focus to it, so
|
|
// the NEXT click on the (now-unfocused) sidebar button was getting swallowed by
|
|
// Obsidian re-focusing the panel — only every other click registered. mousedown
|
|
// fires regardless of focus, so you can spam it and get a tab per click.
|
|
newTabBtn.onmousedown = (e) => {
|
|
if (e.button !== 0) return;
|
|
e.preventDefault(); e.stopPropagation();
|
|
this.onNavigateAway(); void this.plugin.activateViewForFolder(folder);
|
|
};
|
|
|
|
// 0.98.37: open on a SINGLE click. Use mousedown (not click) so it fires even
|
|
// when the sidebar panel wasn't focused yet — Obsidian's "first click focuses
|
|
// the sidebar, second click acts" behavior was forcing a double-click. Ignore
|
|
// non-left buttons and clicks landing on the action buttons.
|
|
row.addEventListener("mousedown", (e) => {
|
|
if (e.button !== 0) return;
|
|
if ((e.target as HTMLElement)?.closest?.(".stashpad-folderpanel-actions")) return;
|
|
this.onNavigateAway(); this.jumpToFolder(folder);
|
|
});
|
|
row.oncontextmenu = (e) => { e.preventDefault(); this.openFolderMenu(e, folder); };
|
|
|
|
// 0.105.1: when expanded, list the folder's Home list-pinned notes as a
|
|
// sub-list below the folder row. Clicking one reveals it in its Stashpad.
|
|
if (homePinned.length > 0 && pinsExpanded) {
|
|
const box = list.createDiv({ cls: "stashpad-folderpanel-pinlist" });
|
|
for (const f of homePinned) {
|
|
const prow = box.createDiv({ cls: "stashpad-folderpanel-pinrow" });
|
|
const fmc = (this.app.metadataCache.getFileCache(f)?.frontmatter ?? {}) as any;
|
|
const pic = prow.createSpan({ cls: "stashpad-folderpanel-pinrow-icon" });
|
|
setIcon(pic, "arrow-up-to-line");
|
|
if (typeof fmc.color === "string") pic.style.color = fmc.color;
|
|
prow.createSpan({ cls: "stashpad-folderpanel-pinrow-label", text: this.titleFromFile(f) });
|
|
prow.onclick = () => { this.onNavigateAway(); void this.plugin.revealNoteInStashpad(f); };
|
|
}
|
|
}
|
|
}
|
|
|
|
/** A folder's ROOT-level (Home) list-pinned notes — backs the folder-panel
|
|
* reveal. Reuses childrenOf(folder, ROOT_ID) + the listPinned frontmatter. */
|
|
private folderHomePinnedNotes(folder: string): TFile[] {
|
|
return this.childrenOf(folder, ROOT_ID).filter((f) =>
|
|
(this.app.metadataCache.getFileCache(f)?.frontmatter as any)?.listPinned === true);
|
|
}
|
|
|
|
/** Collapsible "Hidden (N)" group at the bottom of the Folders list, so hidden
|
|
* folders are restorable in-context (also restorable from the settings tab). */
|
|
private renderHiddenSection(list: HTMLElement, hidden: string[]): void {
|
|
const wrap = list.createDiv({ cls: "stashpad-folderpanel-hidden" });
|
|
const header = wrap.createDiv({ cls: "stashpad-folderpanel-hidden-header" });
|
|
const caret = header.createSpan({ cls: "stashpad-folderpanel-hidden-caret" });
|
|
setIcon(caret, "chevron-right");
|
|
header.createSpan({ cls: "stashpad-folderpanel-hidden-title", text: `Hidden (${hidden.length})` });
|
|
const body = wrap.createDiv({ cls: "stashpad-folderpanel-hidden-body" });
|
|
body.setCssStyles({ display: "none" });
|
|
header.onclick = () => {
|
|
const showing = body.style.display !== "none";
|
|
body.setCssStyles({ display: showing ? "none" : "" });
|
|
setIcon(caret, showing ? "chevron-right" : "chevron-down");
|
|
};
|
|
for (const folder of hidden) {
|
|
const row = body.createDiv({ cls: "stashpad-folderpanel-row stashpad-folderpanel-hidden-row" });
|
|
const name = folder.split("/").pop() || folder;
|
|
row.createSpan({ cls: "stashpad-folderpanel-row-label", text: name });
|
|
const restore = row.createEl("button", { cls: "stashpad-folderpanel-iconbtn" });
|
|
setIcon(restore, "eye");
|
|
restore.setAttr("aria-label", "Unhide");
|
|
restore.onclick = (e) => { e.stopPropagation(); void this.setFolderState(folder, "normal"); };
|
|
}
|
|
}
|
|
|
|
/** Reuse an existing Stashpad tab on this folder if present; else open one.
|
|
* (Routes through openFolderInStashpad so DEFERRED tabs count as existing —
|
|
* the local live-view-only check spawned duplicates for backgrounded tabs.) */
|
|
private jumpToFolder(folder: string): void {
|
|
void this.plugin.openFolderInStashpad(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();
|
|
// Placement (pin / downrank / hide). Each is a toggle; setFolderState clears
|
|
// the other two so a folder is in at most one state.
|
|
const state = this.folderState(folder);
|
|
menu.addItem((i) => i.setTitle(state === "pinned" ? "Unpin" : "Pin to top").setIcon("pin")
|
|
.onClick(() => void this.setFolderState(folder, state === "pinned" ? "normal" : "pinned")));
|
|
menu.addItem((i) => i.setTitle(state === "downranked" ? "Remove downrank" : "Downrank").setIcon("arrow-down")
|
|
.onClick(() => void this.setFolderState(folder, state === "downranked" ? "normal" : "downranked")));
|
|
menu.addItem((i) => i.setTitle("Hide from list").setIcon("eye-off")
|
|
.onClick(() => void this.setFolderState(folder, "hidden")));
|
|
menu.addSeparator();
|
|
menu.addItem((i) => i.setTitle("Rename…").setIcon("pencil")
|
|
.onClick(() => this.renameFolder(folder)));
|
|
// Encryption (Phase 3): lock every top-level note in the folder into separate
|
|
// .stashenc bundles, or unlock them all back. Only when encryption is set up.
|
|
if (this.plugin.encryption?.isConfigured?.()) {
|
|
menu.addSeparator();
|
|
menu.addItem((i) => i.setTitle("Encrypt (lock) all notes in folder").setIcon("lock")
|
|
.onClick(() => void this.plugin.lockFolder(folder)));
|
|
if (this.app.vault.getFiles().some((f) => f.extension === "stashenc" && (f.parent?.path?.replace(/\/+$/, "") ?? "") === folder.replace(/\/+$/, ""))) {
|
|
menu.addItem((i) => i.setTitle("Decrypt (unlock) all notes in folder").setIcon("unlock")
|
|
.onClick(() => void this.plugin.unlockFolder(folder)));
|
|
}
|
|
// (0.136.0: "Mark as archive folder" is gone — every folder owns an
|
|
// `archive/` subfolder now; use "Move selection to archive" in a list, or
|
|
// the aggregated Archived view to browse. Encryption of archived notes is
|
|
// the per-folder "Encrypt archived notes" setting.)
|
|
|
|
// 0.98.31: open the encrypted-trash tab (recoverable secure-deleted notes).
|
|
menu.addItem((i) => i.setTitle("Open encrypted trash").setIcon("rotate-ccw")
|
|
.onClick(() => this.plugin.openEncryptedTrash()));
|
|
}
|
|
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; }
|
|
// 0.140.3 (review): re-validate — the folder could've moved/been deleted
|
|
// by sync while the rename modal sat open.
|
|
if (!(this.app.vault.getAbstractFileByPath(cleaned) instanceof TFolder)) { new Notice("That folder no longer exists."); return; }
|
|
try {
|
|
await this.app.fileManager.renameFile(tf, target);
|
|
// 0.140.3: carry ALL path-keyed settings (default folder + placement +
|
|
// archive + per-folder prefs, incl. descendants) to the new path.
|
|
await this.plugin.remapFolderPathInSettings(cleaned, target);
|
|
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;
|
|
// Closes open tabs, moves the folder to .trash, and posts a persistent
|
|
// notification with an Undo action (move-back). The vault "delete"
|
|
// listener is suppressed for this path so it won't double-notify.
|
|
await this.plugin.deleteStashpadFolderWithUndo(tf);
|
|
},
|
|
).open();
|
|
}
|
|
|
|
/** 0.140.3 (review): run any in-flight divider-drag cleanup on view close so
|
|
* a mid-drag detach doesn't leak the document pointer listeners. */
|
|
async onClose(): Promise<void> { this.dividerCleanup?.(); this.contentEl.empty(); }
|
|
}
|
|
|
|
/** 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" });
|
|
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);
|
|
}
|