0.99.23: cross-folder note paste (cut=move, copy=clone) with attachments + undo/redo

This commit is contained in:
Human 2026-06-12 00:11:12 -07:00
parent 2e67b3d340
commit bbe9229619
8 changed files with 448 additions and 80 deletions

131
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"id": "stashpad",
"name": "Stashpad",
"version": "0.99.20",
"version": "0.99.23",
"minAppVersion": "1.7.0",
"description": "Chat-style nested-notes view: rapid capture, outliner navigation, in-place editing.",
"author": "Human",

View file

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

37
release-notes/0.99.23.md Normal file
View file

@ -0,0 +1,37 @@
# 0.99.23 — Cross-folder note paste
Cut or copy notes in one Stashpad folder and paste them into another. Previously
the clipboard worked only within a single folder; a different-folder paste was
refused.
## Paste across folders
- **Cut → move, copy → clone.** Pasting in the list of a different folder now
moves the notes there (cut) or drops a fresh duplicate (copy). The whole
subtree comes along — the note and all of its children.
- **Attachments travel with the notes.** A moved or copied subtree carries its
referenced attachments into the destination folder's `_attachments`, so the
result is self-contained — no links pointing back at the original folder.
On a move, the source's now-unused attachments are cleaned up; attachments
shared with other notes are left in place.
- **Copies get new identities; moves keep theirs.** A copy is a true duplicate
(fresh ids), so it's independent of the original. A move preserves each note's
id. Pasted roots land where your cursor is.
- **Archive folders are protected.** An auto-encrypting archive folder is
declined as a cross-folder paste target (its key might not be on this device),
with a pointer to the explicit "Move to archive" command. Archive folders also
stay out of cross-folder search, so they don't surface as targets.
## Undo / redo
- **Every cross-folder paste is undoable and redoable.** Undo a move and the
notes (and their attachments) return to the source folder; undo a copy and the
duplicate is removed. Redo replays it. The same applies to pasting a cut into a
note's composer.
## Composer paste
- Pasting a cross-folder cut into a note's **composer** drops the full subtree in
as an indented bullet outline — the note plus its children, nested by depth and
ordered to match the list — then removes the originals (undoable).
</content>

View file

@ -37,7 +37,7 @@ interface SubtreeNode { id: StashpadId; file: TFile; parent: StashpadId | null;
/** Collect a note + all its descendants within `folder` by walking frontmatter
* `parent` links. Returns the root note and the rest, plus the root's parent. */
async function collectSubtree(app: App, folder: string, rootId: StashpadId): Promise<{
export async function collectSubtree(app: App, folder: string, rootId: StashpadId): Promise<{
rootNote: SubtreeNode; descendants: SubtreeNode[]; parentId: StashpadId | null;
} | null> {
const cleaned = folder.replace(/\/+$/, "");

View file

@ -7,7 +7,7 @@ import { STASHPAD_TRASH_VIEW_TYPE } from "./types";
import { StashpadPanelsView, openStashpadPanelsView, PANEL_REGISTRY, type PanelId } from "./panels-view";
import { StashpadFolderPanelView, openFolderPanelView } from "./folder-panel-view";
import { EncryptionService, defaultEncryptionConfig } from "./encryption-service";
import { lockSubtree, unlockBundle, readLockedMeta, type LockResult, deleteEncryptSubtree, restoreDeleted, listDeletedBlobs, readDeletedMeta, deletedRestoreDest, backfillTrashEncrypt, restoreRawTrash, OBSIDIAN_TRASH_DIR, type DeletedMeta } from "./encryption-ops";
import { lockSubtree, unlockBundle, readLockedMeta, type LockResult, deleteEncryptSubtree, restoreDeleted, listDeletedBlobs, readDeletedMeta, deletedRestoreDest, backfillTrashEncrypt, restoreRawTrash, OBSIDIAN_TRASH_DIR, type DeletedMeta, collectSubtree } from "./encryption-ops";
import { EncryptionPasswordModal } from "./modals";
import {
DEFAULT_SETTINGS, StashpadSettings, StashpadSettingTab, setSettings, SETTINGS_TABS,
@ -15,7 +15,7 @@ import {
} from "./settings";
import { DEFAULT_STOPWORDS, bodyToSlug, buildFilename, parseIdFromFilename } from "./slug-service";
import { getActiveView, onActiveViewChange } from "./active-view";
import { importStashZip, STASH_EXT, splitFrontmatter } from "./stash-package";
import { importStashZip, buildStashZip, resolveNoteAttachmentFiles, STASH_EXT, splitFrontmatter } from "./stash-package";
import { formatDateTime } from "./format";
import { resolveStashBytes, isEncryptedStash } from "./stash-crypto";
import { StashpadLog } from "./log";
@ -34,6 +34,9 @@ import { RenderCacheStore } from "./render-cache-store";
* the next load knows to un-ghost the deferred Stashpad tabs. */
const UNGHOST_FLAG = "stashpad:unghost-after-reload";
/** A captured file's content, for snapshot-backed undo/redo of file operations. */
interface FileSnapshot { path: string; binary: boolean; text?: string; data?: ArrayBuffer; }
export default class StashpadPlugin extends Plugin {
settings: StashpadSettings = { ...DEFAULT_SETTINGS };
private undoStacks = new Map<string, UndoStack>();
@ -3290,6 +3293,252 @@ export default class StashpadPlugin extends Plugin {
return (this.settings.archiveFolders ?? []).includes(cleaned);
}
/** Ids of the markdown notes living directly in `folder` (read from DISK the
* metadata cache can lag and an under-read here would miss a real id collision
* on import). */
async idsInFolder(folder: string): Promise<Set<StashpadId>> {
const cleaned = folder.replace(/\/+$/, "");
const out = new Set<StashpadId>();
for (const f of this.app.vault.getMarkdownFiles()) {
if ((f.parent?.path?.replace(/\/+$/, "") ?? "") !== cleaned) continue;
try { const id = splitFrontmatter(await this.app.vault.read(f)).fm.id; if (typeof id === "string") out.add(id); } catch { /* skip unreadable */ }
}
return out;
}
/** Cross-folder note paste engine (cut = move, copy = clone). Routes the source
* subtree(s) through the `.stash` bundle path so ATTACHMENTS travel into the
* destination's `_attachments` folder, then (for a cut) trashes the source
* notes and their EXCLUSIVE attachments. Refuses an archive / auto-encrypting
* destination a missing on-device key could strand the move so that path
* is left to the explicit "Move to archive" command (which checks the key
* first). Returns the destination root ids, total note count, and reversible
* `undo` / `redo` closures (file-level the caller adds tree rebuild + render).
* Undo is snapshot-backed: we capture the created destination files and (for a
* cut) the source files BEFORE trashing them, so undo fully restores either
* direction. Null on refusal / no-op. */
async crossFolderPaste(
srcFolder: string, rootIds: StashpadId[], destFolder: string,
destParent: StashpadId, mode: "cut" | "copy",
): Promise<{ rootIds: StashpadId[]; noteCount: number; undo: () => Promise<void>; redo: () => Promise<void> } | null> {
const cleanDest = destFolder.replace(/\/+$/, "");
if (this.isArchiveFolder(cleanDest)) {
new Notice(`"${cleanDest.split("/").pop()}" auto-encrypts notes moved in, so cross-folder paste is disabled there. Use the "Move to archive" command — it checks the encryption key first.`);
return null;
}
// Gather source subtree(s) from DISK (authoritative; the source folder's view
// may be closed, so we can't rely on an in-memory tree).
const rootNotes: { id: StashpadId; file: TFile }[] = [];
const allDescendants: { id: StashpadId; file: TFile }[] = [];
const srcRootOldIds: StashpadId[] = [];
const srcNoteFiles: TFile[] = [];
for (const rid of rootIds) {
const sub = await collectSubtree(this.app, srcFolder, rid);
if (!sub) continue;
srcRootOldIds.push(sub.rootNote.id);
rootNotes.push({ id: sub.rootNote.id, file: sub.rootNote.file });
srcNoteFiles.push(sub.rootNote.file);
for (const d of sub.descendants) { allDescendants.push({ id: d.id, file: d.file }); srcNoteFiles.push(d.file); }
}
if (!rootNotes.length) return null;
const noteCount = rootNotes.length + allDescendants.length;
// For a CUT, snapshot the source (notes + EXCLUSIVE attachments) BEFORE we
// touch anything, so undo can recreate it byte-for-byte.
let srcExclusiveAtts: TFile[] = [];
let srcSnapshot: FileSnapshot[] = [];
if (mode === "cut") {
srcExclusiveAtts = await this.exclusiveAttachmentsOf(srcNoteFiles);
srcSnapshot = await this.snapshotPaths([...srcNoteFiles.map((f) => f.path), ...srcExclusiveAtts.map((f) => f.path)]);
}
// Bundle the subtree (collects referenced attachments) → import into dest
// (writes attachments into dest/_attachments). Copy → fresh ids; cut → keep
// ids so the moved notes retain their identity. Roots reparent to the paste
// target. dedupeExisting:false on purpose — the vault-wide reuse would route
// the pasted note's attachment link back to the SOURCE folder's copy (and for
// a cut, that copy then gets trashed → a dangling link). We want the
// attachment physically in THIS folder's _attachments so the subtree is
// self-contained. (An identical file already in dest's _attachments is still
// reused — only the cross-folder reuse is dropped.)
const zip = await buildStashZip(this.app, { rootNotes, allDescendants, sourceFolder: srcFolder });
const destExistingIds = await this.idsInFolder(cleanDest);
const beforePaths = new Set(this.filesUnder(cleanDest));
const summary = await importStashZip(this.app, zip, cleanDest, destExistingIds, {
dedupeExisting: false,
forceNewIds: mode === "copy",
reparentRootsTo: destParent,
});
const createdPaths = this.filesUnder(cleanDest).filter((p) => !beforePaths.has(p));
const destSnapshot = await this.snapshotPaths(createdPaths);
const newRootIds = srcRootOldIds.map((old) => summary.idRemap[old]).filter((x): x is StashpadId => !!x);
// CUT: the notes + attachments now live in dest, so trash the source subtree.
if (mode === "cut") {
for (const f of srcNoteFiles) { try { await this.app.fileManager.trashFile(f); } catch (e) { console.warn("[Stashpad] cross-folder move: couldn't trash source note", f.path, e); } }
for (const f of srcExclusiveAtts) { try { await this.app.fileManager.trashFile(f); } catch (e) { console.warn("[Stashpad] cross-folder move: couldn't trash source attachment", f.path, e); } }
}
const removeCreated = async () => {
for (const p of [...createdPaths].reverse()) {
const f = this.app.vault.getAbstractFileByPath(p);
if (f) { try { await this.app.fileManager.trashFile(f as TFile); } catch { /* already gone */ } }
}
};
const undo = mode === "cut"
? async () => { await removeCreated(); await this.restoreSnapshot(srcSnapshot); }
: async () => { await removeCreated(); };
const redo = mode === "cut"
? async () => { await this.restoreSnapshot(destSnapshot); for (const s of srcSnapshot) { const f = this.app.vault.getAbstractFileByPath(s.path); if (f) { try { await this.app.fileManager.trashFile(f as TFile); } catch { /* gone */ } } } }
: async () => { await this.restoreSnapshot(destSnapshot); };
return { rootIds: newRootIds, noteCount, undo, redo };
}
/** Trash (recoverable) the given subtrees in `folder`: every note file plus the
* attachments referenced ONLY by those subtrees shared attachments stay put.
* Returns the files it trashed (for the caller's undo snapshot). */
async trashSubtrees(folder: string, rootIds: StashpadId[]): Promise<TFile[]> {
const files: TFile[] = [];
for (const rid of rootIds) {
const sub = await collectSubtree(this.app, folder, rid);
if (!sub) continue;
files.push(sub.rootNote.file, ...sub.descendants.map((d) => d.file));
}
if (!files.length) return [];
const exclusiveAtts = await this.exclusiveAttachmentsOf(files);
const trashed: TFile[] = [];
for (const f of [...files, ...exclusiveAtts]) {
try { await this.app.fileManager.trashFile(f); trashed.push(f); }
catch (e) { console.warn("[Stashpad] trashSubtrees: couldn't trash", f.path, e); }
}
return trashed;
}
/** Attachments referenced ONLY by `noteFiles` (not by any note outside the set).
* Exclusivity is read from the live resolvedLinks graph call while the notes
* are still present. */
async exclusiveAttachmentsOf(noteFiles: TFile[]): Promise<TFile[]> {
const subtreePaths = new Set(noteFiles.map((f) => f.path));
const subtreeAtts = new Map<string, TFile>();
for (const f of noteFiles) for (const af of await resolveNoteAttachmentFiles(this.app, f)) subtreeAtts.set(af.path, af);
const resolved = this.app.metadataCache.resolvedLinks ?? {};
for (const notePath of Object.keys(resolved)) {
if (subtreePaths.has(notePath)) continue;
for (const target of Object.keys(resolved[notePath] ?? {})) subtreeAtts.delete(target); // referenced elsewhere → not exclusive
}
return [...subtreeAtts.values()];
}
/** Paths of every file (notes + their exclusive attachments) in the given
* subtrees for an undo snapshot taken before trashing. */
async subtreeFilePaths(folder: string, rootIds: StashpadId[]): Promise<string[]> {
const files: TFile[] = [];
for (const rid of rootIds) {
const sub = await collectSubtree(this.app, folder, rid);
if (!sub) continue;
files.push(sub.rootNote.file, ...sub.descendants.map((d) => d.file));
}
if (!files.length) return [];
const atts = await this.exclusiveAttachmentsOf(files);
return [...files.map((f) => f.path), ...atts.map((f) => f.path)];
}
/** Pre-ordered (parent children, depth-tagged) nodes of the given subtrees,
* read from disk for building the indented outline of a CROSS-folder cut
* pasted into a composer (the source folder's tree isn't loaded in the
* destination view). Siblings are ordered by `position` frontmatter (then
* `created`) to match the list's visual order. */
async orderedSubtreeNodes(folder: string, rootIds: StashpadId[]): Promise<Array<{ file: TFile; created: string; depth: number }>> {
const out: Array<{ file: TFile; created: string; depth: number }> = [];
const seen = new Set<StashpadId>();
const posOf = (f: TFile): number => { const v = (this.app.metadataCache.getFileCache(f)?.frontmatter as Record<string, unknown> | undefined)?.position; return typeof v === "number" ? v : Number.MAX_SAFE_INTEGER; };
type N = { id: StashpadId; file: TFile; created: string };
for (const rid of rootIds) {
const sub = await collectSubtree(this.app, folder, rid);
if (!sub) continue;
const childrenOf = new Map<StashpadId, N[]>();
for (const d of sub.descendants) {
if (!d.parent) continue;
const arr = childrenOf.get(d.parent as StashpadId) ?? [];
arr.push({ id: d.id, file: d.file, created: d.created });
childrenOf.set(d.parent as StashpadId, arr);
}
for (const arr of childrenOf.values()) arr.sort((a, b) => (posOf(a.file) - posOf(b.file)) || a.created.localeCompare(b.created));
const walk = (node: N, depth: number): void => {
if (seen.has(node.id)) return; // cycle / overlap guard
seen.add(node.id);
out.push({ file: node.file, created: node.created, depth });
for (const c of childrenOf.get(node.id) ?? []) walk(c, depth + 1);
};
walk({ id: sub.rootNote.id, file: sub.rootNote.file, created: sub.rootNote.created }, 0);
}
return out;
}
/** All file paths under `folder` (notes directly in it + its `_attachments`). */
filesUnder(folder: string): string[] {
const prefix = folder.replace(/\/+$/, "") + "/";
return this.app.vault.getFiles().filter((f) => f.path.startsWith(prefix)).map((f) => f.path);
}
/** Capture content for a set of paths (text for `.md`, binary otherwise) so an
* undo/redo can recreate them exactly. Missing paths are skipped. */
async snapshotPaths(paths: string[]): Promise<FileSnapshot[]> {
const out: FileSnapshot[] = [];
for (const path of paths) {
const f = this.app.vault.getAbstractFileByPath(path) as TFile | null;
if (!f) continue;
const binary = !path.toLowerCase().endsWith(".md");
try {
if (binary) out.push({ path, binary, data: await this.app.vault.readBinary(f) });
else out.push({ path, binary, text: await this.app.vault.read(f) });
} catch (e) { console.warn("[Stashpad] snapshotPaths: couldn't read", path, e); }
}
return out;
}
/** Recreate files from a snapshot (parents are created as needed). Overwrites an
* existing file at the same path. */
async restoreSnapshot(snaps: FileSnapshot[]): Promise<void> {
for (const s of snaps) {
const dir = s.path.split("/").slice(0, -1).join("/");
await this.ensureVaultFolder(dir);
const existing = this.app.vault.getAbstractFileByPath(s.path) as TFile | null;
try {
if (s.binary) {
if (existing) await this.app.vault.adapter.writeBinary(s.path, s.data as ArrayBuffer);
else await this.app.vault.createBinary(s.path, s.data as ArrayBuffer);
} else {
if (existing) await this.app.vault.modify(existing, s.text ?? "");
else await this.app.vault.create(s.path, s.text ?? "");
}
} catch (e) { console.warn("[Stashpad] restoreSnapshot: couldn't write", s.path, e); }
}
}
/** Ensure a (possibly nested) vault folder exists. */
async ensureVaultFolder(dir: string): Promise<void> {
if (!dir) return;
let acc = "";
for (const seg of dir.split("/")) {
acc = acc ? `${acc}/${seg}` : seg;
if (!(await this.app.vault.adapter.exists(acc))) { try { await this.app.vault.createFolder(acc); } catch { /* race / exists */ } }
}
}
/** Rebuild + re-render every open Stashpad view showing `folder` after an
* out-of-band change to its files (e.g. a cross-folder move that removed notes
* from the source folder, whose view isn't the one that ran the command). */
refreshOpenViewsForFolder(folder: string): void {
const cleaned = folder.replace(/\/+$/, "");
for (const leaf of this.app.workspace.getLeavesOfType(STASHPAD_VIEW_TYPE)) {
const v = leaf.view as any;
if ((v?.noteFolder?.replace(/\/+$/, "") ?? "") !== cleaned) continue;
try { v.tree?.rebuild?.(folder); v.render?.(); } catch (e) { console.warn("[Stashpad] refresh view failed", e); }
}
}
/** Batches arrivals per archive folder: each move-in (re)arms a settle timer so
* a multi-file move (a whole subtree dragged in) is swept ONCE, after the
* re-home debounce (900ms) and metadata indexing have settled. */

View file

@ -53,6 +53,9 @@ export interface ImportSummary {
/** Hexname aliases from the manifest, for the caller to merge into the
* destination folder's color aliases (importStashZip has no settings access). */
colorAliases?: Record<string, string>;
/** Old id new id mapping applied on import (identity for kept ids). Lets a
* caller (e.g. cross-folder paste) locate the written roots by their source id. */
idRemap: Record<string, string>;
}
interface ParsedNote {
@ -118,7 +121,7 @@ export async function importStashZip(
buf: ArrayBuffer | Uint8Array,
destFolder: string,
existingIds: Set<StashpadId>,
opts: { dedupeExisting?: boolean } = {},
opts: { dedupeExisting?: boolean; forceNewIds?: boolean; reparentRootsTo?: StashpadId | null } = {},
): Promise<ImportSummary> {
const zip = await JSZip.loadAsync(buf as any);
const manifestFile = zip.file("manifest.json");
@ -152,7 +155,9 @@ export async function importStashZip(
for (const p of parsed) {
const oldId = p.fm.id as string | undefined;
if (!oldId) continue;
if (existingIds.has(oldId) || idRemap.has(oldId) /* dup within zip */) {
if (opts.forceNewIds) {
idRemap.set(oldId, newId(6)); // cross-folder COPY → a fresh identity (not a same-id twin)
} else if (existingIds.has(oldId) || idRemap.has(oldId) /* dup within zip */) {
idRemap.set(oldId, `${oldId}-${newId(4)}-Imported`);
collisionsRenamed++;
} else {
@ -225,13 +230,18 @@ export async function importStashZip(
const oldParent = (p.fm.parent ?? null) as string | null;
let newParent: string | null = oldParent;
if (oldParent && oldParent !== ROOT_ID && idRemap.has(oldParent)) {
newParent = idRemap.get(oldParent)!;
} else if (oldParent && oldParent !== ROOT_ID && !idRemap.has(oldParent)) {
if (!oldParent || oldParent === ROOT_ID) {
// Top-level in the source → a bundle root. Honor reparentRootsTo (cross-
// folder paste nests the pasted root where the cursor was); otherwise keep
// it at ROOT as before.
newParent = opts.reparentRootsTo ?? oldParent ?? ROOT_ID;
} else if (idRemap.has(oldParent)) {
newParent = idRemap.get(oldParent)!; // internal edge — remap to the moved parent
} else {
// Parent isn't in this bundle. If it already EXISTS in the destination
// (e.g. UNLOCK: the locked subtree's parent stayed in the vault), keep the
// link so nesting is restored; otherwise pin to ROOT for safety.
newParent = existingIds.has(oldParent) ? oldParent : ROOT_ID;
// link so nesting is restored; otherwise it's a bundle root → reparent/ROOT.
newParent = existingIds.has(oldParent) ? oldParent : (opts.reparentRootsTo ?? ROOT_ID);
}
// Rewrite body: ![[basename]] -> ![[<routed path>]] (the _attachments copy,
@ -280,7 +290,7 @@ export async function importStashZip(
if (Object.keys(clean).length) colorAliases = clean;
}
return { notesWritten, attachmentsWritten, collisionsRenamed, warnings, colorAliases };
return { notesWritten, attachmentsWritten, collisionsRenamed, warnings, colorAliases, idRemap: Object.fromEntries(idRemap) };
}
// ---------------- Helpers ----------------

View file

@ -6998,7 +6998,40 @@ export class StashpadView extends ItemView {
async cmdPasteNotes(): Promise<void> {
const clip = this.plugin.noteClipboard;
if (!clip) { new Notice("The note clipboard is empty — copy or cut notes first."); return; }
if (clip.folder !== this.noteFolder) { new Notice("Those notes were cut/copied in another folder — cross-folder paste isn't supported yet."); return; }
// Cross-folder paste: the source notes live in another Stashpad folder, so
// route through the plugin's bundle-based engine — it carries ATTACHMENTS
// into this folder's _attachments, mints fresh ids for a copy (keeps them for
// a cut), and refuses an archive/auto-encrypting destination.
if (clip.folder !== this.noteFolder) {
const cursorX = this.currentChildren[this.cursorIdx] ?? null;
const destParent = ((cursorX?.parent ?? this.focusId) ?? ROOT_ID) as StashpadId;
const mode = clip.mode;
const srcFolder = clip.folder;
const result = await this.plugin.crossFolderPaste(srcFolder, clip.ids, this.noteFolder, destParent, mode);
if (!result || !result.rootIds.length) return; // refused (archive) / nothing found — Notice already shown
if (mode === "cut") this.plugin.clearNoteClipboard();
const folder = this.noteFolder;
this.tree.rebuild(folder);
this.pendingFocusIds = result.rootIds.slice();
this.render();
if (mode === "cut") this.plugin.refreshOpenViewsForFolder(srcFolder); // source lost notes
const srcLabel = srcFolder.split("/").pop();
const n = result.rootIds.length;
// Undo/redo: the engine returns reversible file-level closures; we wrap them
// with a rebuild + render of THIS folder and a refresh of the source folder.
const refreshBoth = () => { this.tree.rebuild(folder); this.render(); this.plugin.refreshOpenViewsForFolder(srcFolder); };
this.plugin.getUndoStack(folder).push({
label: `${mode === "cut" ? "Move" : "Paste"} ${n} note${n === 1 ? "" : "s"} from ${srcLabel}`,
undo: async () => { await result.undo(); refreshBoth(); },
redo: async () => { await result.redo(); refreshBoth(); },
});
const verb = mode === "cut" ? "Moved" : "Pasted (copied)";
this.plugin.notifications.show({
message: `${verb} ${n} note${n === 1 ? "" : "s"} (${result.noteCount} total) from "${srcLabel}" into this folder. Undo (in the list) reverses it.`,
kind: "success", category: mode === "cut" ? "move" : "clone", affectedIds: result.rootIds, folder, duration: 0,
});
return;
}
const nodes = clip.ids.map((id) => this.tree.get(id)).filter((n): n is TreeNode => !!n && !!n.file);
if (!nodes.length) { this.plugin.clearNoteClipboard(); this.render(); new Notice("Those notes no longer exist."); return; }
// Paste position: after the cursor row (same parent); fall back to the
@ -7064,7 +7097,45 @@ export class StashpadView extends ItemView {
* deleting the original notes + their subtrees (snapshot-undoable). */
async completeCutIntoComposer(): Promise<void> {
const clip = this.plugin.noteClipboard;
if (!clip || clip.mode !== "cut" || clip.folder !== this.noteFolder) return;
if (!clip || clip.mode !== "cut") return;
if (clip.folder !== this.noteFolder) {
// Cross-folder cut → composer: the cut text is on the system clipboard
// (clip.text); insert it here, then trash the source subtree(s). This is the
// "fold the text into what I'm writing" path — no structural move.
const srcFolder = clip.folder;
const rootIds = clip.ids.slice();
this.plugin.clearNoteClipboard();
// Build the SAME indented bullet outline as the same-folder path (note +
// all children, 2-space indent per depth, optional time prefix), reading
// the source subtree from disk since it isn't in this view's tree.
const ordered = await this.plugin.orderedSubtreeNodes(srcFolder, rootIds);
const prefixTs = getSettings().prefixTimestampsOnCopy;
const outline: string[] = [];
for (const { file, created, depth } of ordered) {
try {
const body = this.stripFrontmatter(await this.app.vault.cachedRead(file)).trim().split(/\r?\n/).join(" ");
const ts = prefixTs ? `${this.formatTimeInline(created)} ` : "";
outline.push(`${" ".repeat(depth)}- ${ts}${body}`);
} catch { /* skip unreadable */ }
}
this.insertIntoComposer(outline.length ? outline.join("\n") : (clip.text ?? ""));
// Snapshot the source BEFORE trashing so undo can restore it.
const snapPaths = await this.plugin.subtreeFilePaths(srcFolder, rootIds);
const snap = await this.plugin.snapshotPaths(snapPaths);
const trashed = await this.plugin.trashSubtrees(srcFolder, rootIds);
this.plugin.refreshOpenViewsForFolder(srcFolder);
const noteN = trashed.filter((f) => f.extension === "md").length;
this.plugin.getUndoStack(srcFolder).push({
label: `Cut ${rootIds.length} note${rootIds.length === 1 ? "" : "s"} into composer (from ${srcFolder.split("/").pop()})`,
undo: async () => { await this.plugin.restoreSnapshot(snap); this.plugin.refreshOpenViewsForFolder(srcFolder); },
redo: async () => { await this.plugin.trashSubtrees(srcFolder, rootIds); this.plugin.refreshOpenViewsForFolder(srcFolder); },
});
this.plugin.notifications.show({
message: `Pasted the text of ${rootIds.length} cut note${rootIds.length === 1 ? "" : "s"} from "${srcFolder.split("/").pop()}" into the composer and removed the original${noteN === 1 ? "" : "s"} (${noteN} note${noteN === 1 ? "" : "s"}). Undo restores them.`,
kind: "warning", category: "delete", affectedIds: rootIds, folder: srcFolder, duration: 0,
});
return;
}
this.plugin.clearNoteClipboard();
const roots = clip.ids.map((id) => this.tree.get(id)).filter((n): n is TreeNode => !!n && !!n.file);
if (!roots.length) return;