0.53.20: stickToListBottom short-circuits the geometric prevAtBottom check

This commit is contained in:
Human 2026-05-14 01:36:44 -07:00
parent 84e61d24bf
commit dcb8179401
27 changed files with 12939 additions and 40 deletions

4
.gitignore vendored
View file

@ -1 +1,5 @@
.DS_Store
node_modules/
data.json
.deploy-target
*.log

23
esbuild.config.mjs Normal file
View file

@ -0,0 +1,23 @@
import esbuild from "esbuild";
const prod = process.argv[2] === "production";
const ctx = await esbuild.context({
entryPoints: ["src/main.ts"],
bundle: true,
external: ["obsidian", "electron", "@codemirror/*", "@lezer/*"],
format: "cjs",
target: "es2020",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
minify: prod,
outfile: "main.js",
});
if (prod) {
await ctx.rebuild();
process.exit(0);
} else {
await ctx.watch();
}

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

686
package-lock.json generated Normal file
View file

@ -0,0 +1,686 @@
{
"name": "stashpad-obsidian",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stashpad-obsidian",
"version": "0.4.1",
"dependencies": {
"jszip": "^3.10.1"
},
"devDependencies": {
"@types/node": "^20.10.0",
"esbuild": "^0.21.0",
"obsidian": "latest",
"typescript": "^5.4.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.38.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/codemirror": {
"version": "5.60.8",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz",
"integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/tern": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/moment": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/obsidian": {
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz",
"integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/codemirror": "5.60.8",
"moment": "2.29.4"
},
"peerDependencies": {
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.38.6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"license": "MIT"
}
}
}

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "stashpad-obsidian",
"version": "0.53.20",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production",
"deploy:files": "node scripts/deploy.mjs",
"deploy": "npm run build && node scripts/deploy.mjs"
},
"devDependencies": {
"@types/node": "^20.10.0",
"esbuild": "^0.21.0",
"obsidian": "latest",
"typescript": "^5.4.0"
},
"dependencies": {
"jszip": "^3.10.1"
}
}

79
scripts/deploy.mjs Normal file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env node
// Copy the three plugin artifacts (main.js, manifest.json, styles.css)
// into the vault's plugin folder. Replaces a symlink-based setup that
// Obsidian Sync was indexing slowly.
//
// Configure the destination once:
// - Either set STASHPAD_DEPLOY in your environment.
// - Or create a `.deploy-target` file at the project root containing
// the absolute path of the vault plugin folder, e.g.:
// /Users/you/Vault/.obsidian/plugins/stashpad
// The file is gitignored.
//
// Usage:
// npm run deploy — build + copy
// npm run deploy:files — copy only (no build)
import { existsSync, readFileSync, copyFileSync, mkdirSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// Use fileURLToPath so spaces (and other URL-encoded chars) in the
// project path don't break the resolved filesystem path.
const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
const ARTIFACTS = ["main.js", "manifest.json", "styles.css"];
function resolveTarget() {
const envTarget = process.env.STASHPAD_DEPLOY?.trim();
if (envTarget) return resolve(envTarget);
const cfgPath = join(ROOT, ".deploy-target");
if (existsSync(cfgPath)) {
const raw = readFileSync(cfgPath, "utf8").trim();
if (raw) return resolve(raw);
}
return null;
}
function fail(msg) {
console.error(`\n[deploy] ${msg}\n`);
process.exit(1);
}
const target = resolveTarget();
if (!target) {
fail(
"No deploy target configured. Set STASHPAD_DEPLOY env var or create a\n" +
".deploy-target file at the project root with the destination path.\n" +
"Example: /Users/you/MyVault/.obsidian/plugins/stashpad",
);
}
// Sanity-check: refuse to write to a path that doesn't look like a
// plugin folder (avoids accidental misconfigurations clobbering things).
const targetParent = dirname(target);
if (!existsSync(target)) {
try {
mkdirSync(target, { recursive: true });
} catch (e) {
fail(`Couldn't create destination folder: ${target}\n${e.message}`);
}
}
if (!existsSync(targetParent)) {
fail(`Parent of destination doesn't exist: ${targetParent}`);
}
let copied = 0;
let missing = [];
for (const name of ARTIFACTS) {
const src = join(ROOT, name);
if (!existsSync(src)) { missing.push(name); continue; }
const dst = join(target, name);
copyFileSync(src, dst);
const sz = statSync(dst).size;
console.log(`[deploy] ${name.padEnd(14)}${dst} (${sz} bytes)`);
copied++;
}
if (missing.length) {
console.warn(`[deploy] WARNING: missing artifacts: ${missing.join(", ")} — did you build?`);
}
console.log(`[deploy] copied ${copied}/${ARTIFACTS.length}${target}`);

19
src/active-view.ts Normal file
View file

@ -0,0 +1,19 @@
let active: any = null;
const listeners = new Set<() => void>();
export function setActiveView(v: any): void {
if (active === v) return;
active = v;
for (const fn of listeners) fn();
}
export function getActiveView(): any { return active; }
export function clearActiveView(v: any): void {
if (active === v) {
active = null;
for (const fn of listeners) fn();
}
}
export function onActiveViewChange(fn: () => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}

View file

@ -0,0 +1,359 @@
import { App, Scope, TFile } from "obsidian";
/**
* Composer autocomplete: a lightweight popup attached to a plain
* <textarea> that suggests tags (after `#`) and wikilink targets
* (after `[[`). Built for Stashpad's composer because Obsidian's
* EditorSuggest API only works against CodeMirror editors.
*
* Lifecycle:
* const ac = new ComposerAutocomplete(app, textareaEl);
* ac.attach(); // start listening
* ac.detach(); // stop and remove popup
*
* Triggers (matched against the substring ending at the caret):
* #foo tag suggestions ("#foo", "#foobar", ...)
* [[foo file suggestions (basenames containing "foo")
*
* Keyboard while popup is open:
* / move highlighted item
* Enter / Tab insert highlighted item
* Escape dismiss without inserting
*
* The popup self-positions just below the textarea and follows scroll/
* resize. It does NOT try to anchor to the caret position (which would
* require a hidden mirror element); textarea-bottom anchoring is good
* enough for a small composer.
*/
export class ComposerAutocomplete {
private popupEl: HTMLDivElement | null = null;
private items: SuggestItem[] = [];
private activeIdx = 0;
private state: AutocompleteState | null = null;
/** Cached lowercased basenames + tag list, refreshed when the vault
* fires create/delete/rename. Avoids re-walking getMarkdownFiles()
* on every keystroke. */
private fileIndex: { basename: string; lower: string; file: TFile }[] = [];
private tagIndex: string[] = [];
private indexBuilt = false;
private vaultListeners: Array<() => void> = [];
/** Obsidian Scope pushed onto the keymap while the popup is open. It
* consumes Escape (and Enter/Tab/Arrow keys are also re-bound here
* belt-and-suspenders) so the workspace's "Escape returns to last
* leaf" handler doesn't fire and yank focus to a previous tab. */
private scope: Scope | null = null;
constructor(private app: App, private ta: HTMLTextAreaElement) {}
attach(): void {
this.ta.addEventListener("input", this.onInput);
this.ta.addEventListener("keydown", this.onKeyDown, true);
this.ta.addEventListener("blur", this.onBlur);
// Document-capture Escape interceptor — only acts while a popup is
// open. Without this, Obsidian's workspace-level Escape (which
// refocuses another tab / split) wins the capture-phase race against
// our textarea-level listener and the user gets thrown off the view.
const doc = this.ta.ownerDocument ?? document;
doc.addEventListener("keydown", this.onDocEscape, true);
this.vaultListeners.push(() => doc.removeEventListener("keydown", this.onDocEscape, true));
this.buildIndex();
// Refresh index on vault structure changes. Coalesce by just
// invalidating; next openFor call rebuilds lazily.
const invalidate = () => { this.indexBuilt = false; };
const v = this.app.vault as any;
v.on("create", invalidate);
v.on("delete", invalidate);
v.on("rename", invalidate);
this.vaultListeners.push(
() => v.off("create", invalidate),
() => v.off("delete", invalidate),
() => v.off("rename", invalidate),
);
}
detach(): void {
this.close();
this.ta.removeEventListener("input", this.onInput);
this.ta.removeEventListener("keydown", this.onKeyDown, true);
this.ta.removeEventListener("blur", this.onBlur);
for (const off of this.vaultListeners) off();
this.vaultListeners = [];
}
// ---------- Index build ----------
private buildIndex(): void {
if (this.indexBuilt) return;
this.fileIndex = this.app.vault.getMarkdownFiles().map((f) => ({
basename: f.basename,
lower: f.basename.toLowerCase(),
file: f,
}));
const tagsRecord = (this.app.metadataCache as any).getTags?.() ?? {};
this.tagIndex = Object.keys(tagsRecord).sort((a, b) =>
(tagsRecord[b] || 0) - (tagsRecord[a] || 0)
);
this.indexBuilt = true;
}
// ---------- Trigger detection ----------
/** Inspect the substring ending at the caret. Return the active
* trigger, or null if no popup should be open. */
private detectTrigger(): AutocompleteState | null {
const value = this.ta.value;
const caret = this.ta.selectionStart;
if (caret == null) return null;
const before = value.slice(0, caret);
// Wikilink: [[ followed by query (no closing ]] yet, no newline)
const linkMatch = before.match(/\[\[([^\]\[\n]*)$/);
if (linkMatch) {
const query = linkMatch[1];
return {
kind: "link",
query,
replaceStart: caret - query.length - 2,
replaceEnd: caret,
};
}
// Tag: # followed by tag chars, preceded by start-of-line/whitespace.
// Require at least one character after the # — opening the popup the
// moment the bare # is typed flooded it with the entire tag list and
// (mysteriously) seemed to coincide with the textarea losing focus.
const tagMatch = before.match(/(^|\s)#([A-Za-z0-9_/\-]+)$/);
if (tagMatch) {
const query = tagMatch[2];
return {
kind: "tag",
query,
replaceStart: caret - query.length - 1, // include the `#`
replaceEnd: caret,
};
}
return null;
}
// ---------- Suggest generation ----------
private buildItems(state: AutocompleteState): SuggestItem[] {
this.buildIndex();
const q = state.query.toLowerCase().trim();
// All-tokens-match: split the query on whitespace; every token must
// appear (anywhere, in any order) in the candidate. So "B and A"
// matches a file titled "A and B". Empty query returns everything.
const tokens = q ? q.split(/\s+/).filter(Boolean) : [];
const matchesAll = (haystack: string): boolean => {
if (!tokens.length) return true;
for (const t of tokens) if (!haystack.includes(t)) return false;
return true;
};
if (state.kind === "link") {
const matches = this.fileIndex
.filter((f) => matchesAll(f.lower))
.slice(0, 30)
.map((f) => ({
label: f.basename,
// Include the opening "[[" — replaceStart points BEFORE the
// brackets, so we re-emit them along with the basename and
// closing brackets to produce a complete wikilink.
insert: `[[${f.basename}]]`,
subtitle: f.file.path,
}));
return matches;
} else {
// Tag autocomplete: same all-tokens rule, just against the
// pre-sorted (by usage count) tag list.
const matches = this.tagIndex
.filter((t) => matchesAll(t.toLowerCase()))
.slice(0, 30)
.map((t) => ({
label: t,
insert: t,
subtitle: "",
}));
return matches;
}
}
// ---------- Event handlers ----------
private onInput = (): void => {
const state = this.detectTrigger();
if (!state) { this.close(); return; }
this.openFor(state);
};
private onBlur = (): void => {
// Defer slightly so a click on a popup item still registers before
// the popup is removed by blur.
setTimeout(() => this.close(), 120);
};
private onDocEscape = (e: KeyboardEvent): void => {
if (e.key !== "Escape") return;
if (!this.state || !this.items.length) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
this.close();
};
private onKeyDown = (e: KeyboardEvent): void => {
if (!this.state || !this.items.length) return;
if (e.key === "ArrowDown") {
e.preventDefault();
this.activeIdx = (this.activeIdx + 1) % this.items.length;
this.refreshActive();
} else if (e.key === "ArrowUp") {
e.preventDefault();
this.activeIdx = (this.activeIdx - 1 + this.items.length) % this.items.length;
this.refreshActive();
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
e.stopPropagation();
this.commit();
} else if (e.key === "Escape") {
// stopImmediatePropagation beats Obsidian's workspace-level
// Escape handler (which would otherwise refocus another tab).
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
this.close();
}
};
// ---------- Popup rendering ----------
private openFor(state: AutocompleteState): void {
this.state = state;
this.items = this.buildItems(state);
this.activeIdx = 0;
if (!this.items.length) { this.close(); return; }
this.renderPopup();
this.pushScope();
}
/** Push an Obsidian keymap Scope that consumes Escape so the
* workspace's "Escape returns to last leaf" handler doesn't fire.
* DOM-level stopPropagation isn't enough Obsidian routes Escape
* through its keymap before bubble-phase listeners run. */
private pushScope(): void {
if (this.scope) return;
const scope = new Scope((this.app as any).scope);
scope.register([], "Escape", (e) => {
e.preventDefault();
this.close();
return false; // mark handled, stop further keymap dispatch
});
this.scope = scope;
(this.app as any).keymap?.pushScope(scope);
}
private popScope(): void {
if (!this.scope) return;
try { (this.app as any).keymap?.popScope(this.scope); } catch {}
this.scope = null;
}
private renderPopup(): void {
if (!this.popupEl) {
// Use the textarea's own document so the popup lands in the same
// window — Obsidian secondary windows have their own document, and
// a plain `document.body` always points at the main window.
const doc = this.ta.ownerDocument ?? document;
this.popupEl = doc.body.createDiv({ cls: "stashpad-composer-suggest" });
// Make sure clicking anywhere on the popup chrome doesn't steal
// focus from the textarea — we'd lose the caret position and the
// input handler context.
this.popupEl.tabIndex = -1;
this.popupEl.addEventListener("mousedown", (e) => e.preventDefault());
}
const pop = this.popupEl;
pop.empty();
for (let i = 0; i < this.items.length; i++) {
const it = this.items[i];
const row = pop.createDiv({ cls: "stashpad-composer-suggest-row" });
if (i === this.activeIdx) row.addClass("is-active");
row.createSpan({ cls: "stashpad-composer-suggest-label", text: it.label });
if (it.subtitle) row.createSpan({ cls: "stashpad-composer-suggest-sub", text: it.subtitle });
// Mousedown (not click) so the textarea blur fires AFTER our handler.
row.onmousedown = (e) => {
e.preventDefault();
this.activeIdx = i;
this.commit();
};
}
this.position();
}
private refreshActive(): void {
if (!this.popupEl) return;
const rows = this.popupEl.children;
for (let i = 0; i < rows.length; i++) {
(rows[i] as HTMLElement).toggleClass("is-active", i === this.activeIdx);
}
// Scroll the active row into view inside the popup (long lists).
const active = rows[this.activeIdx] as HTMLElement | undefined;
if (active) active.scrollIntoView({ block: "nearest" });
}
private position(): void {
if (!this.popupEl) return;
const r = this.ta.getBoundingClientRect();
// Anchor to the textarea's top-left, drop the popup just above the
// textarea so it doesn't get clipped by the composer's bottom edge.
const popH = this.popupEl.offsetHeight || 200;
const top = r.top - popH - 4;
const left = r.left;
this.popupEl.style.left = `${Math.max(8, left)}px`;
this.popupEl.style.top = `${Math.max(8, top)}px`;
this.popupEl.style.minWidth = `${Math.min(360, r.width)}px`;
}
private commit(): void {
if (!this.state || !this.items.length) return;
const item = this.items[this.activeIdx];
if (!item) return;
const before = this.ta.value.slice(0, this.state.replaceStart);
const after = this.ta.value.slice(this.state.replaceEnd);
const insert = item.insert;
this.ta.value = before + insert + after;
const caret = before.length + insert.length;
this.ta.setSelectionRange(caret, caret);
// Fire input so the composer's draft-save and any other listeners catch up.
this.ta.dispatchEvent(new Event("input", { bubbles: true }));
this.close();
this.ta.focus();
}
private close(): void {
if (this.popupEl) {
this.popupEl.remove();
this.popupEl = null;
}
this.state = null;
this.items = [];
this.activeIdx = 0;
this.popScope();
}
}
interface SuggestItem {
label: string;
insert: string;
subtitle: string;
}
interface AutocompleteState {
kind: "tag" | "link";
query: string;
/** Inclusive start index of the trigger (for replacement). For "[[foo"
* this points at the first `[`; for "#foo" at the `#`. */
replaceStart: number;
/** Exclusive end index (the caret). */
replaceEnd: number;
}

125
src/hotkey-recorder.ts Normal file
View file

@ -0,0 +1,125 @@
/**
* Hotkey recorder: a small UI helper that wraps an HTMLInputElement and
* captures the next key chord the user presses. Returns a normalized
* Stashpad-style chord string:
*
* single-key (no modifiers): "S"
* chord with modifiers: "Mod+Shift+ArrowUp"
*
* Modifier order is fixed (Mod, Ctrl, Alt, Shift) so equality compares
* are stable. We translate the OS modifier (Cmd on Mac, Ctrl elsewhere)
* into the literal "Mod" so cross-platform users don't have to think
* about it. A bare Ctrl on Mac stays as "Ctrl" since it's a distinct
* physical key from Cmd.
*
* Usage:
* const stop = startHotkeyRecording(inputEl, (chord) => { ... });
* stop(); // cancel without committing
*/
const isMac = (() => {
try {
return typeof navigator !== "undefined"
&& /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || "");
} catch { return false; }
})();
/** Render a chord for display in inputs / labels. Same format as the stored
* value, since we store the canonical form. */
export function formatChord(chord: string): string {
return chord || "";
}
/** Pretty form for the bindings UI:
* - Mod "Cmd" on Mac, "Ctrl" elsewhere.
* - Alt "Option" on Mac (matches Apple keyboard caps).
*/
export function prettifyChord(chord: string): string {
if (!chord) return "(none)";
let out = chord.replace(/\bMod\b/g, isMac ? "Cmd" : "Ctrl");
if (isMac) out = out.replace(/\bAlt\b/g, "Option");
return out;
}
/** Begin capture on the input. The element gets `is-recording` class
* while active. Calling the returned function aborts capture. */
export function startHotkeyRecording(
input: HTMLInputElement,
onCapture: (chord: string) => void,
opts: { allowSingleKey?: boolean } = { allowSingleKey: true },
): () => void {
const placeholderBefore = input.placeholder;
input.placeholder = "Press a key… (Backspace to cancel)";
input.value = "";
input.classList.add("is-recording");
const cleanup = () => {
input.placeholder = placeholderBefore;
input.classList.remove("is-recording");
input.removeEventListener("keydown", onKeyDown, true);
input.removeEventListener("blur", onBlur);
};
const onKeyDown = (e: KeyboardEvent): void => {
// Ignore standalone modifier presses — wait for the actual key.
if (e.key === "Control" || e.key === "Shift" || e.key === "Alt"
|| e.key === "Meta" || e.key === "OS") return;
e.preventDefault();
e.stopPropagation();
// Backspace cancels recording without binding anything — Esc was a
// bad choice because Obsidian's settings tab also listens for Esc and
// would close the entire window. (On Mac the same key is labeled
// "delete"; on Windows/Linux it's "Backspace".) When Backspace is
// bound to a real shortcut (e.g. as part of Mod+Backspace), the
// modifier prefix arrives as part of the chord — only a BARE
// Backspace cancels.
if (e.key === "Backspace" && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
cleanup();
return;
}
const parts: string[] = [];
// Cmd (Mac) and Ctrl (others) both translate to "Mod". A user pressing
// both Ctrl and Cmd on Mac would yield Mod+Ctrl — a niche case but
// represented honestly.
if (isMac) {
if (e.metaKey) parts.push("Mod");
if (e.ctrlKey) parts.push("Ctrl");
} else {
if (e.ctrlKey) parts.push("Mod");
}
if (e.altKey) parts.push("Alt");
if (e.shiftKey) parts.push("Shift");
const key = normalizeKey(e.key);
if (!key) return;
if (parts.length === 0 && !opts.allowSingleKey) return;
parts.push(key);
const chord = parts.join("+");
cleanup();
onCapture(chord);
};
const onBlur = () => cleanup();
input.addEventListener("keydown", onKeyDown, true);
input.addEventListener("blur", onBlur);
return cleanup;
}
/** Normalize KeyboardEvent.key to our chord vocabulary.
* - Letters uppercase.
* - Arrow / Enter / etc keep canonical name.
* - Symbols (/, ;, etc) → keep as-is. */
function normalizeKey(k: string): string {
if (!k) return "";
if (k.length === 1) {
return k.toUpperCase();
}
// Multi-char keys: keep the canonical KeyboardEvent.key spelling.
// Examples: "ArrowUp", "Enter", "Backspace", "Tab", "Escape", "PageUp".
return k;
}

9
src/id-service.ts Normal file
View file

@ -0,0 +1,9 @@
const ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
export function newId(len = 6): string {
let out = "";
const buf = new Uint8Array(len);
crypto.getRandomValues(buf);
for (let i = 0; i < len; i++) out += ALPHABET[buf[i] % ALPHABET.length];
return out;
}

57
src/integrity-watcher.ts Normal file
View file

@ -0,0 +1,57 @@
import type { TreeIndex } from "./tree-index";
import type { StashpadLog } from "./log";
export class IntegrityWatcher {
constructor(private tree: TreeIndex, private log: StashpadLog) {}
/**
* Compare the current folder's tree against the persisted state and log
* deltas. The state file is shared across folders, so we filter it down
* to entries under `folder` before diffing otherwise switching folders
* would log every note in the previously focused folder as missing.
*/
async sweep(folder?: string): Promise<void> {
const prev = await this.log.readState();
const cur = this.tree.snapshot();
const inFolder = (path: string): boolean => {
if (!folder) return true;
const f = folder.replace(/\/+$/, "");
return path === f || path.startsWith(f + "/");
};
for (const [id, info] of Object.entries(cur)) {
const before = prev[id];
if (!before) {
await this.log.append({ type: "create", id, payload: { path: info.path, parent: info.parent } });
} else if (before.parent !== info.parent) {
await this.log.append({
type: "parent_change", id,
payload: { from: before.parent, to: info.parent },
});
} else if (before.path !== info.path) {
await this.log.append({
type: "rename", id,
payload: { from: before.path, to: info.path },
});
}
}
// Only flag "missing" for notes that lived in the folder we just swept;
// entries from other folders are out of scope for this sweep.
for (const [id, info] of Object.entries(prev)) {
if (!cur[id] && inFolder(info.path)) {
await this.log.append({ type: "missing", id, payload: { lastPath: info.path } });
}
}
// Merge: keep state entries from other folders untouched, replace this
// folder's entries with the fresh snapshot.
const merged: Record<string, { parent: string | null; path: string }> = {};
for (const [id, info] of Object.entries(prev)) {
if (!inFolder(info.path)) merged[id] = info;
}
for (const [id, info] of Object.entries(cur)) merged[id] = info;
await this.log.writeState(merged);
}
}

96
src/log.ts Normal file
View file

@ -0,0 +1,96 @@
import type { App } from "obsidian";
import type { LogEvent } from "./types";
/** StashpadLog persists action history + integrity state in a private
* directory. The directory path is supplied by the caller historically
* it was `.stashpad/` at the vault root; it now defaults to the plugin's
* own private folder (set via the plugin's pluginPrivatePath helper). */
export class StashpadLog {
/** Cached so we only check/create the directory once per session. */
private dirOk = false;
/** Serializes background log writes so concurrent appends don't trample each other. */
private writeChain: Promise<void> = Promise.resolve();
private readonly baseDir: string;
private readonly logPath: string;
private readonly statePath: string;
/** Optional getter the plugin supplies so every appended log line is
* stamped with whoever performed the action. Lazy lookup (called on
* every append) so a name change in settings is reflected on the
* next log entry without re-wiring. Returns "" field omitted. */
private getAuthor: () => string;
constructor(private app: App, baseDir: string, getAuthor?: () => string) {
this.baseDir = baseDir.replace(/\/+$/, "");
this.logPath = `${this.baseDir}/log.jsonl`;
this.statePath = `${this.baseDir}/state.json`;
this.getAuthor = getAuthor ?? (() => "");
}
/** Vault-relative path to the active log.jsonl used by callers that
* want to "Reveal in file explorer" or open the file directly. */
getLogPath(): string { return this.logPath; }
/** Vault-relative path to the directory holding log + state. */
getDir(): string { return this.baseDir; }
private async ensureDir(): Promise<void> {
if (this.dirOk) return;
const adapter = this.app.vault.adapter;
// mkdir intermediates (the dir might be nested several levels deep
// under .obsidian/plugins/...).
const parts = this.baseDir.split("/").filter(Boolean);
let cur = "";
for (const p of parts) {
cur = cur ? `${cur}/${p}` : p;
if (!(await adapter.exists(cur))) await adapter.mkdir(cur);
}
this.dirOk = true;
}
/** Fire-and-forget append. The line is queued behind any in-flight writes
* and persisted in the background. The log is write-only (nothing in the
* plugin reads it back), so there's no need for callers to await this.
* Errors are logged to the browser console instead of bubbling up. */
append(ev: Omit<LogEvent, "ts"> & { ts?: string }): Promise<void> {
// Auto-stamp author from the plugin's authorName setting if the
// caller didn't already supply one. Most call sites don't think
// about authorship; centralising it here means every log entry
// (create / parent_change / delete / etc.) carries who did it.
const author = ev.author ?? this.getAuthor() ?? "";
const stamped: any = { ts: new Date().toISOString(), ...ev };
if (author) stamped.author = author; else delete stamped.author;
const line = JSON.stringify(stamped) + "\n";
this.writeChain = this.writeChain.then(async () => {
try {
await this.ensureDir();
const adapter: any = this.app.vault.adapter;
if (typeof adapter.append === "function") {
await adapter.append(this.logPath, line);
} else {
// Fallback for adapters without append(): existing read+rewrite.
const existing = (await adapter.exists(this.logPath)) ? await adapter.read(this.logPath) : "";
await adapter.write(this.logPath, existing + line);
}
} catch (e) {
console.warn("Stashpad: log append failed", e);
}
});
// Resolve immediately — callers that `await` get an instantly-resolved promise.
return Promise.resolve();
}
async readState(): Promise<Record<string, { parent: string | null; path: string }>> {
const adapter = this.app.vault.adapter;
if (!(await adapter.exists(this.statePath))) return {};
try {
return JSON.parse(await adapter.read(this.statePath));
} catch {
return {};
}
}
async writeState(state: Record<string, { parent: string | null; path: string }>): Promise<void> {
await this.ensureDir();
await this.app.vault.adapter.write(this.statePath, JSON.stringify(state, null, 2));
}
}

1366
src/main.ts Normal file

File diff suppressed because it is too large Load diff

789
src/modals.ts Normal file
View file

@ -0,0 +1,789 @@
import { App, Modal, Platform, moment, Notice } from "obsidian";
interface LogEv { ts: string; type: string; id: string; payload?: any; author?: string; }
export class LogModal extends Modal {
private events: LogEv[] = [];
/** Currently-displayed slice of events (events filtered by typeFilter,
* if set). Pagination + render counts always go through this. */
private visible: LogEv[] = [];
private shownCount = 0;
private typeFilter: string | null = null;
private listEl: HTMLDivElement | null = null;
private footerEl: HTMLDivElement | null = null;
private countEl: HTMLSpanElement | null = null;
private filterSelEl: HTMLSelectElement | null = null;
private static PAGE = 100;
constructor(app: App, private text: string, private jsonlPath: string) { super(app); }
onOpen(): void {
this.contentEl.empty();
this.titleEl.setText("Stashpad log");
this.modalEl.addClass("stashpad-log-modal");
this.events = [];
for (const line of this.text.trim().split(/\r?\n/)) {
if (!line) continue;
try { this.events.push(JSON.parse(line)); } catch {}
}
this.events.reverse();
const toolbar = this.contentEl.createDiv({ cls: "stashpad-log-toolbar" });
this.countEl = toolbar.createSpan({ cls: "stashpad-log-count" }) as HTMLSpanElement;
this.updateCount();
// Type filter dropdown. Built lazily here, repopulated by
// refreshTypeFilter whenever events change (e.g. after Clear log).
this.filterSelEl = toolbar.createEl("select", { cls: "stashpad-log-type-filter" });
this.filterSelEl.onchange = () => this.setTypeFilter(this.filterSelEl!.value || null);
this.refreshTypeFilter();
const revealBtn = toolbar.createEl("button", { text: "Reveal JSONL" });
revealBtn.onclick = () => this.shellAct("reveal");
const openBtn = toolbar.createEl("button", { text: "Open in default app" });
openBtn.onclick = () => this.shellAct("open");
const copyBtn = toolbar.createEl("button", { text: "Copy raw JSONL" });
let copyResetTimer: number | null = null;
copyBtn.onclick = async () => {
await navigator.clipboard.writeText(this.text);
copyBtn.setText("Copied");
if (copyResetTimer != null) window.clearTimeout(copyResetTimer);
copyResetTimer = window.setTimeout(() => { copyBtn.setText("Copy raw JSONL"); copyResetTimer = null; }, 1500);
};
const exportBtn = toolbar.createEl("button", { text: "Export copy" });
exportBtn.onclick = () => this.exportCopy();
const clearBtn = toolbar.createEl("button", { cls: "mod-warning", text: "Clear log" });
clearBtn.onclick = () => this.clearLog();
this.listEl = this.contentEl.createDiv({ cls: "stashpad-log-list" }) as HTMLDivElement;
this.refreshList();
this.footerEl = this.contentEl.createDiv({ cls: "stashpad-log-footer" }) as HTMLDivElement;
this.renderFooter();
}
/** Recompute `visible` from `events` + `typeFilter`, then re-render
* the list from scratch (resetting pagination). */
private refreshList(): void {
if (!this.listEl) return;
this.visible = this.typeFilter
? this.events.filter((ev) => ev.type === this.typeFilter)
: this.events.slice();
this.shownCount = 0;
this.listEl.empty();
if (!this.visible.length) {
this.listEl.createDiv({
cls: "stashpad-log-empty",
text: this.typeFilter ? `No "${this.typeFilter}" events.` : "No events yet.",
});
this.updateCount();
return;
}
this.appendMore(LogModal.PAGE);
}
private setTypeFilter(type: string | null): void {
if ((this.typeFilter ?? null) === (type ?? null)) return;
this.typeFilter = type;
this.refreshList();
this.renderFooter();
}
/** Rebuild the type-filter dropdown options from the current events.
* Called on first render and after Clear log. */
private refreshTypeFilter(): void {
if (!this.filterSelEl) return;
const sel = this.filterSelEl;
sel.empty();
const counts = new Map<string, number>();
for (const ev of this.events) counts.set(ev.type, (counts.get(ev.type) ?? 0) + 1);
const entries = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const all = sel.createEl("option", { text: `All types (${this.events.length})` });
all.value = "";
for (const [type, n] of entries) {
const opt = sel.createEl("option", { text: `${type} (${n})` });
opt.value = type;
}
// If the previously-selected filter no longer applies (e.g. its
// type was cleared away), drop it.
if (this.typeFilter && !counts.has(this.typeFilter)) {
this.typeFilter = null;
}
sel.value = this.typeFilter ?? "";
}
private updateCount(): void {
if (!this.countEl) return;
const total = this.visible.length;
const labelTotal = this.typeFilter
? `${total} ${this.typeFilter} event${total === 1 ? "" : "s"}`
: `${total} event${total === 1 ? "" : "s"}`;
if (this.shownCount === 0 || this.shownCount >= total) {
this.countEl.setText(labelTotal);
} else {
this.countEl.setText(`Showing ${this.shownCount} of ${labelTotal}`);
}
}
private appendMore(n: number): void {
if (!this.listEl) return;
const stop = Math.min(this.visible.length, this.shownCount + n);
for (let i = this.shownCount; i < stop; i++) {
this.listEl.appendChild(this.renderEvent(this.visible[i]));
}
this.shownCount = stop;
this.updateCount();
}
private renderFooter(): void {
if (!this.footerEl) return;
this.footerEl.empty();
// Pagination math now runs against the FILTERED set (visible).
const remaining = this.visible.length - this.shownCount;
if (remaining <= 0) return;
const moreBtn = this.footerEl.createEl("button", {
text: `Load ${Math.min(LogModal.PAGE, remaining)} more`,
});
moreBtn.onclick = () => { this.appendMore(LogModal.PAGE); this.renderFooter(); };
if (remaining > LogModal.PAGE) {
const allBtn = this.footerEl.createEl("button", { text: `Load all (${remaining})` });
allBtn.onclick = () => { this.appendMore(remaining); this.renderFooter(); };
}
}
private shellAct(kind: "reveal" | "open"): void {
try {
const full = (this.app.vault.adapter as any).getFullPath?.(this.jsonlPath);
if (!full) throw new Error("no full path");
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { shell } = (window as any).require("electron");
if (kind === "reveal") shell.showItemInFolder(full);
else shell.openPath(full);
} catch (e) {
new Notice(`Couldn't ${kind}: ${(e as Error).message}`);
}
}
/** Write the current log content to <log-dir>/<timestamp>-log.jsonl
* same directory as the active log so the copy lives next to its
* source. Derived from this.jsonlPath rather than hardcoded. */
private async exportCopy(): Promise<string | null> {
try {
const stamp = (moment as any)().format("YYYY-MM-DD_HHmmss");
const dir = this.jsonlPath.replace(/\/[^/]+$/, "") || "";
const exportPath = dir ? `${dir}/${stamp}-log.jsonl` : `${stamp}-log.jsonl`;
await this.app.vault.adapter.write(exportPath, this.text);
new Notice(`Exported log → ${exportPath}`);
return exportPath;
} catch (e) {
new Notice(`Export failed: ${(e as Error).message}`);
return null;
}
}
private clearLog(): void {
new ConfirmModal(
this.app,
"Clear log?",
"A timestamped copy will be saved alongside the active log before it's truncated.",
"Export & clear",
async (ok) => {
if (!ok) return;
const exported = await this.exportCopy();
if (!exported) return; // bail if export failed; don't risk data loss
try {
await this.app.vault.adapter.write(this.jsonlPath, "");
} catch (e) {
new Notice(`Clear failed: ${(e as Error).message}`);
return;
}
this.text = "";
this.events = [];
this.typeFilter = null;
this.shownCount = 0;
// Rebuild the dropdown so it shows just "All types (0)" again
// and resets to that option.
this.refreshTypeFilter();
// refreshList renders the empty state and resets counts.
this.refreshList();
if (this.footerEl) this.footerEl.empty();
new Notice("Log cleared.");
},
).open();
}
private renderEvent(ev: LogEv): HTMLElement {
const row = document.createElement("div");
row.className = `stashpad-log-row stashpad-log-${ev.type}`;
const when = (moment as any)(ev.ts).isValid()
? (moment as any)(ev.ts).format("YYYY-MM-DD HH:mm:ss")
: ev.ts;
const ts = document.createElement("span");
ts.className = "stashpad-log-ts";
ts.textContent = when;
row.appendChild(ts);
const type = document.createElement("span");
type.className = "stashpad-log-type";
type.textContent = ev.type;
row.appendChild(type);
// Author column — author of THIS log entry (not the note's author).
// For "create" actions this is the same as the note's author; for
// "parent_change", "rename", "delete", etc., it tells you who
// performed the action regardless of who originally created the
// note. Older log lines without this field render as "—" so the
// column stays aligned.
const author = document.createElement("span");
author.className = "stashpad-log-author";
author.textContent = ev.author ? ev.author : "—";
if (!ev.author) author.classList.add("is-empty");
row.appendChild(author);
const msg = document.createElement("span");
msg.className = "stashpad-log-msg";
msg.textContent = this.describe(ev);
row.appendChild(msg);
return row;
}
private describe(ev: LogEv): string {
const p = ev.payload ?? {};
switch (ev.type) {
case "create": return `Created "${p.path ?? ev.id}" under ${p.parent ?? "?"}`;
case "delete": {
const atts = Array.isArray(p.attachmentsRemoved) ? p.attachmentsRemoved.length : 0;
const merged = p.mergedInto ? ` (merged into ${p.mergedInto})` : "";
return `Deleted ${ev.id}${merged}${atts ? ` — also removed ${atts} attachment${atts === 1 ? "" : "s"}` : ""}`;
}
case "missing": return `Missing: ${p.lastPath ?? ev.id}`;
case "parent_change": {
const reason = p.reason ? ` (${p.reason})` : "";
return `Moved ${ev.id} from ${p.from ?? "null"}${p.to ?? "null"}${reason}`;
}
case "rename": return `Renamed ${p.from ?? "?"}${p.to ?? "?"}`;
case "reorder": {
const n = p.count ?? 1;
const dir = p.dir ?? "?";
const parent = p.parent ?? ev.id ?? "?";
return `Reordered ${n} note${n === 1 ? "" : "s"} (${dir}) under ${parent}`;
}
case "complete": {
const n = p.count ?? 1;
const tag = p.undo ? " (undo)" : p.redo ? " (redo)" : "";
return `Marked ${n} note${n === 1 ? "" : "s"} complete${tag}`;
}
case "uncomplete": {
const n = p.count ?? 1;
const tag = p.undo ? " (undo)" : p.redo ? " (redo)" : "";
return `Unmarked ${n} note${n === 1 ? "" : "s"}${tag}`;
}
case "stash_export": {
const n = p.noteCount ?? "?";
return `Exported ${n} note${n === 1 ? "" : "s"}${p.path ?? "?"}`;
}
case "stash_import": {
const n = p.noteCount ?? "?";
const extras: string[] = [];
if (p.attachmentsWritten) extras.push(`${p.attachmentsWritten} attachment${p.attachmentsWritten === 1 ? "" : "s"}`);
if (p.collisionsRenamed) extras.push(`${p.collisionsRenamed} renamed`);
const tail = extras.length ? ` (${extras.join(", ")})` : "";
return `Imported ${n} note${n === 1 ? "" : "s"} from ${p.from ?? "?"}${p.into ?? "?"}${tail}`;
}
case "attachment_add": return `Added attachment ${p.path ?? ev.id}`;
case "attachment_remove": return `Removed attachment ${p.path ?? ev.id}`;
case "palette_color_add": return `Added palette color ${p.color ?? ev.id}`;
case "palette_color_remove": return `Removed palette color ${p.color ?? ev.id}`;
default: return JSON.stringify(p);
}
}
onClose(): void { this.contentEl.empty(); }
}
export class ConfirmDeleteModal extends Modal {
constructor(
app: App,
private noteTitle: string,
private descendantCount: number,
private attachmentCount: number,
/** When false, the "Also delete attachments" checkbox is suppressed
* and onChoose is invoked with deleteAttachments=false. Lets the
* caller open the modal purely for descendant/multi-select
* confirmation while honoring the "Offer to delete attachments"
* setting being OFF. */
private offerAttachmentDelete: boolean,
private onChoose: (deleteAttachments: boolean) => void,
) { super(app); }
onOpen(): void {
this.contentEl.empty();
this.titleEl.setText(`Delete "${this.noteTitle}"?`);
const parts: string[] = [];
if (this.descendantCount > 0) parts.push(`${this.descendantCount} descendant note${this.descendantCount === 1 ? "" : "s"} will also be deleted.`);
if (this.attachmentCount > 0) parts.push(`${this.attachmentCount} attachment${this.attachmentCount === 1 ? "" : "s"} found across these notes.`);
this.contentEl.createEl("p", { text: parts.join(" ") });
let deleteAtts = this.offerAttachmentDelete && this.attachmentCount > 0;
if (this.offerAttachmentDelete && this.attachmentCount > 0) {
const label = this.contentEl.createEl("label", { cls: "stashpad-modal-check" });
const cb = label.createEl("input", { type: "checkbox" }) as HTMLInputElement;
cb.checked = deleteAtts;
cb.onchange = () => { deleteAtts = cb.checked; };
label.createSpan({ text: " Also delete attachments" });
}
const row = this.contentEl.createDiv({ cls: "stashpad-modal-btns" });
const cancel = row.createEl("button", { text: "Cancel" });
cancel.onclick = () => this.close();
const del = row.createEl("button", { cls: "mod-warning", text: "Delete" });
del.onclick = () => { this.close(); this.onChoose(deleteAtts); };
}
onClose(): void { this.contentEl.empty(); }
}
export class SplitNoteModal extends Modal {
private lines: string[];
/** Line mode: index of the FIRST line of the second part (1..lines.length-1). */
private lineCursorIdx: number;
private mode: "line" | "cursor" = "line";
private cursorTextarea: HTMLTextAreaElement | null = null;
constructor(
app: App,
private body: string,
private onSplitAtLine: (firstLineOfSecondPart: number) => void,
private onSplitAtChar: (charIndex: number) => void,
) {
super(app);
this.lines = body.replace(/\r\n/g, "\n").split("\n");
this.lineCursorIdx = Math.max(1, Math.min(this.lines.length - 1, Math.floor(this.lines.length / 2)));
// Single-line notes can only be split via cursor mode.
if (this.lines.length < 2) this.mode = "cursor";
}
onOpen(): void {
this.titleEl.setText("Split note");
this.modalEl.addClass("stashpad-split-modal");
this.render();
// Tab toggles modes when both are available.
this.scope.register([], "Tab", (e) => {
if (this.lines.length < 2) return;
e.preventDefault();
this.mode = this.mode === "line" ? "cursor" : "line";
this.render();
});
// Enter (no mods): commit in line mode; in cursor mode, let textarea insert a newline.
this.scope.register([], "Enter", (e) => {
if (this.mode !== "line") return; // pass through to textarea
e.preventDefault();
this.commitLine();
});
// Mod+Enter: commit in cursor mode.
this.scope.register(["Mod"], "Enter", (e) => {
if (this.mode !== "cursor") return;
e.preventDefault();
this.commitCursor();
});
// Arrows: in line mode, move the divider. In cursor mode, let textarea handle them.
this.scope.register([], "ArrowUp", (e) => {
if (this.mode !== "line") return;
e.preventDefault();
this.lineCursorIdx = Math.max(1, this.lineCursorIdx - 1);
this.render();
});
this.scope.register([], "ArrowDown", (e) => {
if (this.mode !== "line") return;
e.preventDefault();
this.lineCursorIdx = Math.min(this.lines.length - 1, this.lineCursorIdx + 1);
this.render();
});
}
private commitLine(): void {
const idx = this.lineCursorIdx;
this.close();
this.onSplitAtLine(idx);
}
private commitCursor(): void {
const ta = this.cursorTextarea;
if (!ta) return;
const ch = ta.selectionStart;
if (ch <= 0 || ch >= ta.value.length) {
new Notice("Move the cursor inside the text — neither end can be empty.");
return;
}
this.close();
this.onSplitAtChar(ch);
}
private render(): void {
this.contentEl.empty();
// Top bar: mode toggle on the left, Confirm button on the right.
// The confirm button is essential on mobile where Enter is hijacked
// by the textarea (cursor mode) or doesn't have a physical key
// (some on-screen keyboards send "Done" instead).
const bar = this.contentEl.createDiv({ cls: "stashpad-split-toggle-bar" });
if (this.lines.length >= 2) {
const lineBtn = bar.createEl("button", { text: "Line split", cls: "stashpad-split-mode-btn" });
if (this.mode === "line") lineBtn.addClass("is-active");
lineBtn.onclick = () => { this.mode = "line"; this.render(); };
}
const curBtn = bar.createEl("button", { text: "Cursor split", cls: "stashpad-split-mode-btn" });
if (this.mode === "cursor") curBtn.addClass("is-active");
curBtn.onclick = () => { this.mode = "cursor"; this.render(); };
const confirmBtn = bar.createEl("button", {
text: "Split", cls: "stashpad-split-confirm-btn mod-cta",
});
confirmBtn.onmousedown = (e) => e.preventDefault(); // don't blur the textarea
confirmBtn.onclick = () => {
if (this.mode === "line") this.commitLine();
else this.commitCursor();
};
if (this.mode === "line") this.renderLineMode();
else this.renderCursorMode();
const help = this.contentEl.createDiv({ cls: "stashpad-split-help" });
if (Platform.isMobile) {
help.setText(this.mode === "line"
? "Tap a line to position the divider, then Split."
: "Tap inside the text to position the cursor, then Split.");
} else {
help.setText(this.mode === "line"
? "↑/↓ pick split line · Enter or Split confirm · Tab → cursor mode · Esc cancel · Children stay with the first part"
: "Click or arrow to position cursor · Mod+Enter or Split confirm · Tab → line mode · Esc cancel · Children stay with the first part");
}
}
private renderLineMode(): void {
const list = this.contentEl.createDiv({ cls: "stashpad-split-list" });
for (let i = 0; i < this.lines.length; i++) {
if (i === this.lineCursorIdx) {
list.createDiv({ cls: "stashpad-split-divider", text: "── split here ──" });
}
const ln = list.createDiv({ cls: "stashpad-split-line" });
ln.createSpan({ cls: "stashpad-split-lineno", text: String(i + 1) });
ln.createSpan({ cls: "stashpad-split-text", text: this.lines[i] || " " });
// Tap-to-position on mobile: tapping a line moves the divider to
// start of THAT line (it becomes the first line of the second
// part). On desktop this is also a nicer UX than only arrows.
ln.onclick = () => {
const target = Math.max(1, Math.min(this.lines.length - 1, i));
if (target === this.lineCursorIdx) return;
this.lineCursorIdx = target;
this.render();
};
}
}
private renderCursorMode(): void {
const wrap = this.contentEl.createDiv({ cls: "stashpad-split-cursor-wrap" });
const ta = wrap.createEl("textarea", { cls: "stashpad-split-cursor-ta" }) as HTMLTextAreaElement;
ta.value = this.body;
ta.readOnly = false;
this.cursorTextarea = ta;
// Auto-size the textarea to fit content. Cap at 3 lines on mobile,
// 12 lines on desktop. Recomputed on input in case the user edits.
const lineHeight = parseFloat(getComputedStyle(ta).lineHeight) || 22;
const maxLines = Platform.isMobile ? 3 : 12;
const minLines = 2;
const fit = (): void => {
ta.style.height = "auto";
const needed = Math.min(ta.scrollHeight, lineHeight * maxLines + 16);
ta.style.height = `${Math.max(needed, lineHeight * minLines + 16)}px`;
};
requestAnimationFrame(() => {
fit();
const mid = Math.floor(ta.value.length / 2);
ta.focus();
ta.setSelectionRange(mid, mid);
});
ta.addEventListener("input", fit);
}
onClose(): void {
this.cursorTextarea = null;
this.contentEl.empty();
}
}
/** Submenu opened from the "+" tile in ColorPickerModal. Lets the user pick
* any hex color, then either save (apply once) or add (apply + persist to
* the palette). Closing without a button applies as "save" (consistent
* with the user's hasty-apply expectation). */
export class CustomColorModal extends Modal {
private value: string;
private delivered = false;
constructor(
app: App,
seed: string | null,
private onResult: (color: string | null, opts: { addToPalette: boolean }) => void,
) {
super(app);
this.value = seed && /^#[0-9a-f]{6}$/i.test(seed) ? seed : "#888888";
}
onOpen(): void {
this.contentEl.empty();
this.titleEl.setText("Custom color");
this.modalEl.addClass("stashpad-custom-color-modal");
const row = this.contentEl.createDiv({ cls: "stashpad-custom-color-row" });
const preview = row.createDiv({ cls: "stashpad-custom-color-preview" });
preview.style.background = this.value;
// Native wheel — clicking the preview pops the OS color picker.
const wheel = row.createEl("input", { type: "color" }) as HTMLInputElement;
wheel.value = this.value;
wheel.addClass("stashpad-custom-color-wheel");
preview.onclick = () => wheel.click();
// Hex text input for direct entry. Synced both ways with the wheel.
const hex = row.createEl("input", { type: "text" }) as HTMLInputElement;
hex.addClass("stashpad-custom-color-hex");
hex.placeholder = "#RRGGBB";
hex.value = this.value;
hex.maxLength = 7;
const sync = (next: string) => {
const v = next.startsWith("#") ? next : "#" + next;
if (!/^#[0-9a-f]{6}$/i.test(v)) return;
this.value = v;
preview.style.background = v;
wheel.value = v;
if (hex.value !== v) hex.value = v;
};
wheel.oninput = () => sync(wheel.value);
hex.oninput = () => sync(hex.value);
const footer = this.contentEl.createDiv({ cls: "stashpad-color-footer" });
const cancel = footer.createEl("button", { text: "Cancel" });
cancel.onclick = () => { this.delivered = true; this.close(); };
const save = footer.createEl("button", { cls: "mod-cta", text: "Save" });
save.title = "Apply this color to the selection (without adding to your palette).";
save.onclick = () => this.commit({ addToPalette: false });
const add = footer.createEl("button", { cls: "mod-cta", text: "Add to palette" });
add.title = "Apply and save this color so it appears as a tile next time.";
add.onclick = () => this.commit({ addToPalette: true });
this.scope.register([], "Enter", (e) => { e.preventDefault(); this.commit({ addToPalette: false }); });
this.scope.register(["Mod"], "Enter", (e) => { e.preventDefault(); this.commit({ addToPalette: true }); });
requestAnimationFrame(() => hex.focus());
}
private commit(opts: { addToPalette: boolean }): void {
this.delivered = true;
this.close();
this.onResult(this.value, opts);
}
onClose(): void {
if (!this.delivered) {
// Click-out / Esc → apply hastily (no palette persistence), matching
// the user's "skip adding" intent.
this.delivered = true;
this.onResult(this.value, { addToPalette: false });
}
this.contentEl.empty();
}
}
/** Compact color picker. Presents a grid of preset swatches and the user's
* saved custom palette, plus a "+" tile that opens CustomColorModal for
* arbitrary hex picking. The "no color" tile (slash) clears the assignment.
*
* Behavior:
* - Arrow keys navigate the grid; Enter activates the highlighted tile.
* For preset/saved tiles that means apply-and-close. For the "+" tile
* it opens the custom-color submenu (this modal stays open behind it).
* - There is no Save button on this modal clicking a preset is the
* explicit "apply" action.
*/
export class ColorPickerModal extends Modal {
static DEFAULT_PALETTE: string[] = [
"#E07A78", "#E08A47", "#E0A744", "#B0CC6E", "#6BC07A",
"#5BA9CE", "#9B82C9", "#C57AB5", "#D75AA8",
];
/** Active grid index for keyboard nav. -1 = no focus yet. */
private focusIdx = -1;
/** Snapshot of selectable tiles in render order. The "+" tile is `kind:
* "add"` and opens CustomColorModal rather than committing directly. */
private items: { kind: "none" | "preset" | "custom" | "add"; color: string | null; el: HTMLElement }[] = [];
constructor(
app: App,
private currentColor: string | null,
private customPalette: string[],
private onPick: (color: string | null, opts: { addToPalette?: boolean }) => void,
/** Optional: invoked when the user clicks the on a saved tile.
* The host should remove the color from settings.customPalette and log
* the deletion. Returning a fresh palette array updates this modal in
* place so the user can keep deleting. */
private onDeleteCustom?: (color: string) => Promise<string[]> | string[],
) { super(app); }
onOpen(): void {
this.contentEl.empty();
this.titleEl.setText("Pick a color");
this.modalEl.addClass("stashpad-color-modal");
const grid = this.contentEl.createDiv({ cls: "stashpad-color-grid" });
this.items = [];
// "No color" tile (slash).
const noTile = grid.createDiv({ cls: "stashpad-color-tile stashpad-color-none" });
noTile.title = "No color";
noTile.onclick = () => this.activate(0);
this.items.push({ kind: "none", color: null, el: noTile });
const allPresets = [...ColorPickerModal.DEFAULT_PALETTE, ...this.customPalette];
for (const c of allPresets) {
const tile = grid.createDiv({ cls: "stashpad-color-tile" });
tile.style.background = c;
tile.title = c;
const isCustom = !ColorPickerModal.DEFAULT_PALETTE.some((d) => d.toLowerCase() === c.toLowerCase());
const idx = this.items.length;
tile.onclick = () => this.activate(idx);
// Saved-custom tiles get a ✕ that deletes them from the persisted
// palette. Defaults aren't deletable.
if (isCustom && this.onDeleteCustom) {
const del = tile.createSpan({ cls: "stashpad-color-tile-del", text: "×" });
del.title = "Remove from palette";
del.onclick = (e) => {
e.stopPropagation();
void this.handleDelete(c);
};
}
this.items.push({ kind: isCustom ? "custom" : "preset", color: c, el: tile });
}
// "+" tile that opens the custom-color submenu.
const addTile = grid.createDiv({ cls: "stashpad-color-tile stashpad-color-add" });
addTile.title = "Custom color…";
const addIdx = this.items.length;
addTile.onclick = () => this.activate(addIdx);
this.items.push({ kind: "add", color: null, el: addTile });
// Initial focus: the tile matching currentColor, else the first preset.
const seedIdx = this.items.findIndex((it) => {
if (this.currentColor === null) return it.kind === "none";
if (!this.currentColor) return false;
return it.color !== null && it.color.toLowerCase() === this.currentColor.toLowerCase();
});
this.focusIdx = seedIdx >= 0 ? seedIdx : 0;
this.refreshActive();
// Keyboard nav: arrows move focus; Enter activates.
this.scope.register([], "ArrowRight", (e) => { e.preventDefault(); this.moveFocus(1); });
this.scope.register([], "ArrowLeft", (e) => { e.preventDefault(); this.moveFocus(-1); });
this.scope.register([], "ArrowDown", (e) => { e.preventDefault(); this.moveFocus(this.columns()); });
this.scope.register([], "ArrowUp", (e) => { e.preventDefault(); this.moveFocus(-this.columns()); });
this.scope.register([], "Enter", (e) => { e.preventDefault(); this.activate(this.focusIdx); });
// After paint, focus the modal so arrow keys land here, not in the
// background view.
requestAnimationFrame(() => (this.modalEl as HTMLElement).focus());
}
/** Click or Enter on a tile. Preset/saved/none apply immediately + close.
* Add open the custom-color submenu (this modal hands off and closes). */
private activate(i: number): void {
if (i < 0 || i >= this.items.length) return;
this.focusIdx = i;
this.refreshActive();
const it = this.items[i];
if (it.kind === "add") {
// Hand off to the custom submenu. We close this modal first so we
// don't stack two on top of each other; the submenu fires onPick.
this.close();
new CustomColorModal(this.app, this.currentColor, (color, opts) => {
this.onPick(color, opts);
}).open();
return;
}
this.close();
this.onPick(it.color, { addToPalette: false });
}
/** Approximate column count from the rendered grid (for vertical arrows). */
private columns(): number {
if (!this.items.length) return 1;
const grid = this.items[0].el.parentElement;
if (!grid) return 1;
const gridRect = grid.getBoundingClientRect();
const tileRect = this.items[0].el.getBoundingClientRect();
if (tileRect.width <= 0) return 1;
// Rough: total width / (tile + gap). We use the displayed positions to
// count tiles in the first row instead — more robust to gap rounding.
const firstTop = tileRect.top;
let cols = 0;
for (const it of this.items) {
const r = it.el.getBoundingClientRect();
if (Math.abs(r.top - firstTop) < 1) cols++;
else break;
}
void gridRect;
return Math.max(1, cols);
}
private async handleDelete(color: string): Promise<void> {
if (!this.onDeleteCustom) return;
try {
const next = await this.onDeleteCustom(color);
// Re-render the grid in place with the updated palette so the user
// can keep tidying.
this.customPalette = Array.isArray(next) ? next : this.customPalette.filter((c) => c.toLowerCase() !== color.toLowerCase());
this.onOpen();
} catch (e) {
// Swallow: host can show its own notice.
console.warn("Stashpad: palette delete failed", e);
}
}
private moveFocus(delta: number): void {
if (!this.items.length) return;
const next = Math.max(0, Math.min(this.items.length - 1, this.focusIdx + delta));
this.focusIdx = next;
this.refreshActive();
}
private refreshActive(): void {
for (let i = 0; i < this.items.length; i++) {
this.items[i].el.toggleClass("is-active", i === this.focusIdx);
}
}
onClose(): void { this.contentEl.empty(); }
}
export class ConfirmModal extends Modal {
constructor(
app: App,
private titleText: string,
private message: string,
private confirmText: string,
private onChoose: (confirmed: boolean) => void,
) { super(app); }
onOpen(): void {
this.contentEl.empty();
this.titleEl.setText(this.titleText);
this.contentEl.createEl("p", { text: this.message });
const row = this.contentEl.createDiv({ cls: "stashpad-modal-btns" });
const cancel = row.createEl("button", { text: "Cancel" });
cancel.onclick = () => { this.close(); this.onChoose(false); };
const ok = row.createEl("button", { cls: "mod-cta", text: this.confirmText });
ok.onclick = () => { this.close(); this.onChoose(true); };
// Focus the confirm button so Enter accepts.
requestAnimationFrame(() => ok.focus());
}
onClose(): void { this.contentEl.empty(); }
}

249
src/note-picker.ts Normal file
View file

@ -0,0 +1,249 @@
import { App, SuggestModal, TFile } from "obsidian";
import type { TreeIndex } from "./tree-index";
import type { TreeNode } from "./types";
import { ROOT_ID } from "./types";
export interface PickerItem {
id: string;
label: string;
node: TreeNode | null;
kind: "note" | "create";
bodyPreview?: string; // for search mode
matchLine?: number;
/** For cross-folder results: the source folder + raw TFile so the
* caller can switch view + focus appropriately. Empty/undefined for
* local (current-tree) results. */
crossFolder?: string;
crossFile?: TFile;
}
/** A cross-folder note loaded from another Stashpad. Shaped to plug into
* the same render/filter machinery as in-tree notes without inventing a
* full synthetic TreeNode. */
export interface CrossFolderNote {
file: TFile;
folder: string;
/** Note's id from frontmatter (just for stable item ids in the list). */
id: string;
/** Rendered title/label (basename minus the trailing -id, with dashes
* spaces). */
title: string;
/** Pre-loaded body text (the picker will lazy-read if blank). */
body: string;
/** Pre-loaded parent body's first line, prefixed with "Parent: " by
* the renderer. Optional. */
parentBlurb?: string;
}
interface NoteBody {
node: TreeNode | null;
title: string;
body: string;
/** When set, this entry is from another Stashpad. */
cross?: CrossFolderNote;
}
export class StashpadSuggest extends SuggestModal<PickerItem> {
private notes: NoteBody[] = [];
constructor(
app: App,
private tree: TreeIndex,
private titleFn: (n: TreeNode) => string,
private opts: {
mode: "pick" | "search";
placeholder?: string;
allowCreate?: boolean;
onPick: (item: PickerItem) => void;
onCreate?: (query: string) => 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
* exhausted (or to fill out short result lists). */
crossFolderNotes?: () => CrossFolderNote[];
},
) {
super(app);
this.setPlaceholder(opts.placeholder ?? (opts.mode === "search" ? "Search notes…" : "Pick a note…"));
this.loadAll();
}
private loadAll(): void {
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: "" });
}
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: "" });
for (const c of this.tree.getChildren(ROOT_ID)) walk(c.id, 1);
// lazy-read bodies in background
for (const n of this.notes) {
if (!n.node?.file) continue;
this.app.vault.cachedRead(n.node.file).then((md) => { n.body = this.stripFm(md); });
}
// Cross-folder notes (loaded once on first request, then cached on
// this.notes alongside local entries — kept distinguished by the
// .cross marker for tier ordering and rendering).
if (this.opts.crossFolderNotes) {
const cross = this.opts.crossFolderNotes();
for (const c of cross) {
this.notes.push({ node: null, title: c.title, body: c.body, cross: c });
}
for (const n of this.notes) {
if (!n.cross || n.body) continue;
this.app.vault.cachedRead(n.cross.file).then((md) => { n.body = this.stripFm(md); });
}
}
}
private stripFm(md: string): string {
if (!md.startsWith("---")) return md;
const end = md.indexOf("\n---", 3);
return end === -1 ? md : md.slice(end + 4).replace(/^\r?\n/, "");
}
getSuggestions(query: string): PickerItem[] {
const q = query.trim().toLowerCase();
// Tier the candidates: local first (notes from the active tree),
// then cross-folder (notes from other Stashpads). The user wanted
// cross-folder results to appear AFTER the local ones rather than
// intermingled, and only "kick in" once the local tier has been
// exhausted (or shown as available).
const local = this.notes.filter((n) => !n.cross);
const cross = this.notes.filter((n) => n.cross);
const buildItem = (n: NoteBody, matchLine: number): PickerItem => ({
id: n.cross ? `cross:${n.cross.id}` : n.node!.id,
label: n.title,
node: n.node,
kind: "note",
bodyPreview: this.previewFromBody(n.body, matchLine),
matchLine,
crossFolder: n.cross?.folder,
crossFile: n.cross?.file,
});
const matchTier = (tier: NoteBody[]): PickerItem[] => {
const out: PickerItem[] = [];
for (const n of tier) {
if (this.opts.mode === "search") {
if (!q) { out.push(buildItem(n, -1)); continue; }
const titleHit = n.title.toLowerCase().includes(q);
const lines = n.body.split(/\r?\n/);
let matchLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(q)) { matchLine = i; break; }
}
if (!titleHit && matchLine === -1) continue;
out.push(buildItem(n, matchLine));
} else {
// pick mode
if (q && !n.title.toLowerCase().includes(q) && !n.body.toLowerCase().includes(q)) continue;
out.push(buildItem(n, -1));
}
}
return out;
};
const localItems = matchTier(local);
const items: PickerItem[] = [...localItems];
// Only consult the cross-folder tier when local results are sparse
// OR when the user is in search mode with a real query (so they can
// discover cross-folder hits). Pick mode without a query keeps the
// list local for performance.
const crossWanted = this.opts.mode === "search"
? (q ? true : localItems.length < 10)
: (q ? localItems.length < 30 : false);
if (crossWanted) {
const crossItems = matchTier(cross);
// Cap the local tier so the user sees the cross-folder section
// come up without scrolling through hundreds of local hits.
if (this.opts.mode === "search" && !q) items.length = Math.min(items.length, 50);
items.push(...crossItems);
}
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" });
}
return items;
}
private previewFromBody(body: string, matchLine: number): string {
const lines = body.split(/\r?\n/).filter((l) => l.trim().length > 0);
if (!lines.length) return "";
if (matchLine < 0) return lines.slice(0, 3).join("\n");
const start = Math.max(0, matchLine - 1);
const end = Math.min(lines.length, matchLine + 2);
return lines.slice(start, end).join("\n");
}
renderSuggestion(item: PickerItem, el: HTMLElement): void {
el.addClass("stashpad-suggest-item");
if (item.kind === "create") {
el.createDiv({ cls: "stashpad-suggest-create", text: item.label });
return;
}
if (item.crossFolder) el.addClass("is-cross-folder");
// Locate the underlying NoteBody so we can render body + parent body.
const note = this.notes.find((n) => {
if (item.crossFolder) return n.cross?.id === item.id.replace(/^cross:/, "");
return n.node?.id === item.id;
});
// Top line: body's first non-empty line (or fallback to the label).
const bodyTop = this.firstLineOfBody(note?.body ?? "") || item.label.trim();
const top = el.createDiv({ cls: "stashpad-suggest-title", text: bodyTop });
if (item.crossFolder) {
top.createSpan({
cls: "stashpad-suggest-folder",
text: ` · ${item.crossFolder.split("/").pop() || item.crossFolder}`,
});
}
// Bottom line: parent body. For local results, walk the tree. For
// cross-folder results, the loader pre-supplies parentBlurb.
let parentBlurb = "";
if (item.crossFolder) parentBlurb = note?.cross?.parentBlurb ?? "";
else parentBlurb = this.parentBlurbFor(item.node);
if (parentBlurb) {
const prev = el.createDiv({ cls: "stashpad-suggest-preview" });
prev.setText(`Parent: ${parentBlurb}`);
}
}
/** First non-empty line of a body string, with markdown noise trimmed. */
private firstLineOfBody(body: string): string {
if (!body) return "";
const lines = body.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) return trimmed;
}
return "";
}
/** First non-empty body line of the given node's parent. Reads from the
* already-loaded body cache (lazy reads populate it in the background;
* the line just won't render until that resolves). */
private parentBlurbFor(node: TreeNode | null): string {
if (!node || !node.parent || node.parent === ROOT_ID) return "";
const parent = this.tree.get(node.parent);
if (!parent || !parent.file) return "";
const parentEntry = this.notes.find((n) => n.node?.id === parent.id);
return parentEntry ? this.firstLineOfBody(parentEntry.body) : "";
}
onChooseSuggestion(item: PickerItem): void {
if (item.kind === "create" && this.opts.onCreate) {
this.opts.onCreate((this as any).inputEl?.value ?? "");
return;
}
this.opts.onPick(item);
}
}

150
src/order-store.ts Normal file
View file

@ -0,0 +1,150 @@
import type { App } from "obsidian";
import type { StashpadId } from "./types";
const ORDER_FILE = ".stashpad-order.json";
/** Per-folder ordering store. Maintains a `{ parentId: [childId, ...] }` map
* in `<folder>/.stashpad-order.json`. Children present in the map sort by
* their position in the array; children NOT in the map fall back to their
* created-timestamp order, sorted after the explicit ones. */
export class OrderStore {
private cache = new Map<string, Record<string, string[]>>(); // folder -> map
constructor(private app: App) {}
/** Returns the order map for a folder (cached). Never throws. */
async load(folder: string): Promise<Record<string, string[]>> {
if (this.cache.has(folder)) return this.cache.get(folder)!;
const path = `${folder}/${ORDER_FILE}`;
const adapter = this.app.vault.adapter;
let map: Record<string, string[]> = {};
try {
if (await adapter.exists(path)) {
const text = await adapter.read(path);
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
for (const [k, v] of Object.entries(parsed)) {
if (Array.isArray(v) && v.every((x) => typeof x === "string")) map[k] = v as string[];
}
}
}
} catch (e) { console.warn("Stashpad: order load failed", e); }
this.cache.set(folder, map);
return map;
}
/** Schedule a debounced write of the order map. The cache is updated by
* setOrder/appendChild/removeChild synchronously; this method only controls
* when the cache lands on disk. A short coalescing window collapses bursts
* of reorder writes (held-down keyboard repeat, rapid drag-and-drop) into
* a single round-trip meaningful on a network drive where each
* adapter.write is a separate hop.
*
* `flush()` (below) forces an immediate write and is called on view
* teardown so an Obsidian close during the debounce window can't drop
* the latest order. */
async save(folder: string): Promise<void> {
this.scheduleWrite(folder);
}
/** Force any pending debounced write for `folder` to land NOW. Idempotent. */
async flush(folder: string): Promise<void> {
const t = this.pendingTimers.get(folder);
if (t != null) {
window.clearTimeout(t);
this.pendingTimers.delete(folder);
}
await this.writeNow(folder);
}
private pendingTimers = new Map<string, number>();
private writeInFlight = new Map<string, Promise<void>>();
private scheduleWrite(folder: string): void {
const existing = this.pendingTimers.get(folder);
if (existing != null) window.clearTimeout(existing);
const t = window.setTimeout(() => {
this.pendingTimers.delete(folder);
void this.writeNow(folder);
}, 150);
this.pendingTimers.set(folder, t);
}
/** Serialize concurrent writes per folder so a flush() racing with a
* scheduled write can't reorder the on-disk content. */
private async writeNow(folder: string): Promise<void> {
const prev = this.writeInFlight.get(folder) ?? Promise.resolve();
const next = prev.then(() => this.doWrite(folder));
this.writeInFlight.set(folder, next);
try { await next; } finally {
if (this.writeInFlight.get(folder) === next) this.writeInFlight.delete(folder);
}
}
private async doWrite(folder: string): Promise<void> {
const map = this.cache.get(folder) ?? {};
const trimmed: Record<string, string[]> = {};
for (const [k, v] of Object.entries(map)) if (v.length > 0) trimmed[k] = v;
this.cache.set(folder, trimmed);
const path = `${folder}/${ORDER_FILE}`;
const adapter = this.app.vault.adapter;
try {
if (Object.keys(trimmed).length === 0) {
// Skip the exists() probe — remove() throws "file not found" if
// it's missing, which we swallow. One round-trip instead of two
// on a network drive.
try { await adapter.remove(path); } catch { /* file already gone */ }
} else {
await adapter.write(path, JSON.stringify(trimmed, null, 2));
}
} catch (e) { console.warn("Stashpad: order save failed", e); }
}
/** Get the explicit order for a parent (may be empty). Synchronous — caller must load first. */
getOrder(folder: string, parentId: StashpadId): string[] {
return this.cache.get(folder)?.[parentId]?.slice() ?? [];
}
/** Replace the order for a parent. */
setOrder(folder: string, parentId: StashpadId, ids: string[]): void {
const map = this.cache.get(folder) ?? {};
map[parentId] = ids.slice();
this.cache.set(folder, map);
}
/** Append a child to the parent's order (no-op if already present). */
appendChild(folder: string, parentId: StashpadId, childId: StashpadId): void {
const map = this.cache.get(folder) ?? {};
const arr = map[parentId] ?? [];
if (!arr.includes(childId)) arr.push(childId);
map[parentId] = arr;
this.cache.set(folder, map);
}
/** Remove a child from any parent's order (used on delete / move). */
removeChild(folder: string, childId: StashpadId): void {
const map = this.cache.get(folder);
if (!map) return;
for (const arr of Object.values(map)) {
const i = arr.indexOf(childId);
if (i >= 0) arr.splice(i, 1);
}
}
/** Drop the cache for a folder (forces re-read on next load). */
invalidate(folder: string): void { this.cache.delete(folder); }
}
/** Sort children using an explicit-order array; unknown ids fall to the end
* in their existing order (typically created-timestamp order). */
export function sortChildrenByOrder<T extends { id: string }>(children: T[], order: string[]): T[] {
if (!order.length) return children;
const positions = new Map<string, number>();
order.forEach((id, i) => positions.set(id, i));
return children.slice().sort((a, b) => {
const pa = positions.has(a.id) ? positions.get(a.id)! : Infinity;
const pb = positions.has(b.id) ? positions.get(b.id)! : Infinity;
if (pa !== pb) return pa - pb;
return 0; // stable: rely on input order for ties (already sorted by created)
});
}

1069
src/settings.ts Normal file

File diff suppressed because it is too large Load diff

58
src/slug-service.ts Normal file
View file

@ -0,0 +1,58 @@
/** Default stop-words trimmed out of slugs. Editable in settings. */
export const DEFAULT_STOPWORDS = [
"a","an","the","and","or","but","if","then","else","of","in","on","at","to",
"for","with","by","from","as","is","are","was","were","be","been","being",
"i","you","he","she","it","we","they","this","that","these","those","my",
"your","our","their","do","does","did","so","just","very","really","im",
];
const MAX_LEN = 50;
export function bodyToSlug(body: string, stopwords: string[] = DEFAULT_STOPWORDS): string {
const stopSet = stopwords instanceof Set ? stopwords : new Set(stopwords.map((s) => s.toLowerCase()));
const firstLine = (body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "").trim();
if (!firstLine) return "Untitled";
// Lowercase only for the stop-word filter; the emitted slug is Proper-Cased
// so Obsidian's title display reads naturally ("My First Note" not
// "my first note").
// Strip common English contraction tails BEFORE removing punctuation,
// so "Stash's" becomes "Stash" (not "Stash s") and we don't end up with
// an orphan "s" segment in the slug. The character class covers ASCII
// ', unicode right single quote U+2019, modifier letter apostrophe
// U+02BC, and the common smart-quote variants U+2018/201A/201B.
const cleaned = firstLine.replace(/['-ʼ](s|t|re|ll|ve|d|m)\b/gi, "");
// Set of contraction tails to drop if they survive as bare tokens
// (because the apostrophe was an exotic variant we missed). Without
// this, "I'm falling asleep" → "M Falling Asleep" when the right-single-
// quote stripper missed.
const CONTRACTION_TAILS = new Set(["s", "t", "re", "ll", "ve", "d", "m"]);
const words = cleaned
.replace(/[^A-Za-z0-9\s]+/g, " ")
.split(/\s+/)
.filter((w) => w && !stopSet.has(w.toLowerCase()) && !CONTRACTION_TAILS.has(w.toLowerCase()))
.map((w) => {
// Smart proper-case: preserve all-caps tokens (HCC, NASA, US, etc.)
// so acronyms don't read as "Hcc". A token counts as all-caps if
// every alphabetic char is uppercase AND it has at least 2 chars
// (single letters like "A" stay first-cap-only). Mixed-case tokens
// get the standard "first up, rest down" treatment.
if (w.length >= 2 && /^[A-Z0-9]+$/.test(w)) return w;
return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
});
let slug = "";
for (const w of words) {
const next = slug ? `${slug}-${w}` : w;
if (next.length > MAX_LEN) break;
slug = next;
}
return slug || "Untitled";
}
export function buildFilename(slug: string, id: string): string {
return `${slug}-${id}.md`;
}
export function parseIdFromFilename(basename: string): string | null {
const m = basename.match(/-([a-z0-9]{4,12})$/);
return m ? m[1] : null;
}

151
src/sort-store.ts Normal file
View file

@ -0,0 +1,151 @@
import type { App } from "obsidian";
import type { StashpadId } from "./types";
/** Per-folder sort-mode store. Mirrors the OrderStore pattern: maintains a
* `{ parentId: SortMode }` map in `<folder>/.stashpad-sort.json`, loaded
* on view bootstrap and consulted by the tree's orderProvider.
*
* - "manual" is the default and is NEVER persisted (it's the absence of
* an entry). Auto-flip-to-manual on drag/keyboard reorder is implemented
* by deleting the parent's entry.
* - Any non-manual mode is persisted explicitly so navigating away and
* back restores the user's chosen sort for each parent independently.
* - Granularity is per-parent each parent in the tree carries its own
* sort mode (or falls through to manual). The home/root parent uses
* ROOT_ID as its key. */
export type SortMode =
| "manual"
| "created-asc"
| "created-desc"
| "modified-asc"
| "modified-desc"
| "title-az"
| "title-za";
export const SORT_MODE_LABELS: Record<SortMode, string> = {
"manual": "Manual",
"created-asc": "Created — oldest first",
"created-desc": "Created — newest first",
"modified-asc": "Modified — oldest first",
"modified-desc": "Modified — newest first",
"title-az": "Title — A→Z",
"title-za": "Title — Z→A",
};
export const SORT_MODES_ORDER: SortMode[] = [
"manual",
"created-asc", "created-desc",
"modified-asc", "modified-desc",
"title-az", "title-za",
];
const VALID_MODES = new Set<string>(SORT_MODES_ORDER);
const SORT_FILE = ".stashpad-sort.json";
export class SortStore {
/** folder -> { parentId -> SortMode } (excluding "manual" entries) */
private cache = new Map<string, Record<string, SortMode>>();
constructor(private app: App) {}
/** Load the per-parent sort-mode map for a folder. Never throws. */
async load(folder: string): Promise<Record<string, SortMode>> {
if (this.cache.has(folder)) return this.cache.get(folder)!;
const path = `${folder}/${SORT_FILE}`;
const adapter = this.app.vault.adapter;
const map: Record<string, SortMode> = {};
try {
if (await adapter.exists(path)) {
const parsed = JSON.parse(await adapter.read(path));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
for (const [k, v] of Object.entries(parsed)) {
if (typeof v === "string" && VALID_MODES.has(v)) map[k] = v as SortMode;
}
}
}
} catch (e) {
console.warn("Stashpad: sort load failed", e);
}
this.cache.set(folder, map);
return map;
}
/** Schedule a debounced write of the sort-mode map. See OrderStore.save for
* the rationale same pattern, same window. */
async save(folder: string): Promise<void> {
this.scheduleWrite(folder);
}
/** Force any pending debounced write for `folder` to land NOW. Idempotent. */
async flush(folder: string): Promise<void> {
const t = this.pendingTimers.get(folder);
if (t != null) {
window.clearTimeout(t);
this.pendingTimers.delete(folder);
}
await this.writeNow(folder);
}
private pendingTimers = new Map<string, number>();
private writeInFlight = new Map<string, Promise<void>>();
private scheduleWrite(folder: string): void {
const existing = this.pendingTimers.get(folder);
if (existing != null) window.clearTimeout(existing);
const t = window.setTimeout(() => {
this.pendingTimers.delete(folder);
void this.writeNow(folder);
}, 150);
this.pendingTimers.set(folder, t);
}
private async writeNow(folder: string): Promise<void> {
const prev = this.writeInFlight.get(folder) ?? Promise.resolve();
const next = prev.then(() => this.doWrite(folder));
this.writeInFlight.set(folder, next);
try { await next; } finally {
if (this.writeInFlight.get(folder) === next) this.writeInFlight.delete(folder);
}
}
private async doWrite(folder: string): Promise<void> {
const map = this.cache.get(folder) ?? {};
const path = `${folder}/${SORT_FILE}`;
const adapter = this.app.vault.adapter;
try {
if (Object.keys(map).length === 0) {
// Skip the exists() probe — remove() throws if it's missing, which
// we swallow. One round-trip instead of two on a network drive.
try { await adapter.remove(path); } catch { /* file already gone */ }
} else {
await adapter.write(path, JSON.stringify(map, null, 2));
}
} catch (e) {
console.warn("Stashpad: sort save failed", e);
}
}
/** Get a parent's sort mode. Falls back to "manual" when no entry exists. */
getMode(folder: string, parentId: StashpadId): SortMode {
return this.cache.get(folder)?.[parentId] ?? "manual";
}
/** Set a parent's sort mode. Passing "manual" deletes the entry (manual is the
* absence of an entry keeps the json file compact). */
setMode(folder: string, parentId: StashpadId, mode: SortMode): void {
const map = this.cache.get(folder) ?? {};
if (mode === "manual") delete map[parentId];
else map[parentId] = mode;
this.cache.set(folder, map);
}
/** Drop a parent's entry — used when a parent note is deleted. */
removeParent(folder: string, parentId: StashpadId): void {
const map = this.cache.get(folder);
if (!map) return;
delete map[parentId];
}
invalidate(folder: string): void { this.cache.delete(folder); }
}

285
src/stash-package.ts Normal file
View file

@ -0,0 +1,285 @@
import JSZip from "jszip";
import { App, TFile, parseYaml, stringifyYaml } from "obsidian";
import { newId } from "./id-service";
import { ROOT_ID, type StashpadId } from "./types";
export const STASH_EXT = "stash";
export const SCHEMA_VERSION = 1;
const ATTACHMENT_LINK_RE = /!\[\[([^\]\|]+)(?:\|[^\]]+)?\]\]/g;
export interface StashManifest {
stashSchema: number;
exportedAt: string;
sourceFolder: string;
noteCount: number;
rootIds: StashpadId[];
}
export interface ExportInput {
/** Notes to export. Children of these notes will be auto-included. */
rootNotes: { id: StashpadId; file: TFile }[];
/** Children-of-roots resolver (recursive walk handled by caller). */
allDescendants: { id: StashpadId; file: TFile }[];
/** Folder the source notes live in (for the manifest). */
sourceFolder: string;
}
export interface ImportSummary {
notesWritten: number;
attachmentsWritten: number;
collisionsRenamed: number;
warnings: string[];
}
interface ParsedNote {
originalName: string;
fm: Record<string, any>;
body: string;
}
// ---------------- Export ----------------
export async function buildStashZip(app: App, input: ExportInput): Promise<Uint8Array> {
const zip = new JSZip();
const allNotes = dedupeById([...input.rootNotes, ...input.allDescendants]);
const collectedAtts = new Map<string, ArrayBuffer>(); // basename -> binary
const warnings: string[] = [];
for (const n of allNotes) {
const md = await app.vault.read(n.file);
let rewritten = md;
const refs = extractAttachmentRefs(md);
for (const ref of refs) {
const af = app.metadataCache.getFirstLinkpathDest(ref, n.file.path);
if (!af) {
warnings.push(`Missing attachment "${ref}" in ${n.file.path}`);
continue;
}
const basename = af.name;
if (!collectedAtts.has(basename)) {
collectedAtts.set(basename, await app.vault.readBinary(af));
}
// Rewrite: ![[some/path/foo.png]] -> ![[foo.png]]
rewritten = rewriteAttachmentRef(rewritten, ref, basename);
}
// Also normalize attachments: list in frontmatter to bare basenames.
rewritten = rewriteFrontmatterAttachmentList(rewritten, app, n.file.path);
zip.file(`notes/${n.file.name}`, rewritten);
}
for (const [name, buf] of collectedAtts) {
zip.file(`attachments/${name}`, buf);
}
const manifest: StashManifest = {
stashSchema: SCHEMA_VERSION,
exportedAt: new Date().toISOString(),
sourceFolder: input.sourceFolder,
noteCount: allNotes.length,
rootIds: input.rootNotes.map((n) => n.id),
};
zip.file("manifest.json", JSON.stringify(manifest, null, 2));
if (warnings.length) {
zip.file("warnings.txt", warnings.join("\n"));
}
return zip.generateAsync({ type: "uint8array", compression: "DEFLATE", compressionOptions: { level: 6 } });
}
// ---------------- Import ----------------
export async function importStashZip(
app: App,
buf: ArrayBuffer | Uint8Array,
destFolder: string,
existingIds: Set<StashpadId>,
): Promise<ImportSummary> {
const zip = await JSZip.loadAsync(buf as any);
const manifestFile = zip.file("manifest.json");
if (!manifestFile) throw new Error("Not a valid .stash package: missing manifest.json");
const manifest = JSON.parse(await manifestFile.async("string")) as StashManifest;
if (typeof manifest.stashSchema !== "number" || manifest.stashSchema > SCHEMA_VERSION) {
throw new Error(`Unsupported .stash schema: v${manifest.stashSchema}`);
}
await ensureFolder(app, destFolder);
// Read all note entries.
const noteEntries = Object.values(zip.files).filter(
(f) => !f.dir && f.name.startsWith("notes/") && f.name.endsWith(".md"),
);
const parsed: ParsedNote[] = [];
for (const f of noteEntries) {
const content = await f.async("string");
const { fm, body } = splitFrontmatter(content);
parsed.push({ originalName: f.name.slice("notes/".length), fm, body });
}
// Build id remap (collision-aware).
const idRemap = new Map<StashpadId, StashpadId>();
let collisionsRenamed = 0;
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 */) {
idRemap.set(oldId, `${oldId}-${newId(4)}-Imported`);
collisionsRenamed++;
} else {
idRemap.set(oldId, oldId);
}
}
const importDate = new Date().toISOString();
const warnings: string[] = [];
const attachmentsFolder = `${destFolder}/_attachments`;
// Write attachments first so notes referencing them land on disk first.
let attachmentsWritten = 0;
const attEntries = Object.values(zip.files).filter(
(f) => !f.dir && f.name.startsWith("attachments/"),
);
if (attEntries.length > 0) await ensureFolder(app, attachmentsFolder);
for (const f of attEntries) {
const basename = f.name.slice("attachments/".length);
if (!basename) continue;
const destPath = `${attachmentsFolder}/${basename}`;
if (await app.vault.adapter.exists(destPath)) continue; // dedup by name
const buf = await f.async("arraybuffer");
await app.vault.createBinary(destPath, buf);
attachmentsWritten++;
}
// Write notes with remapped ids/parents and import_date.
let notesWritten = 0;
for (const p of parsed) {
const oldId = p.fm.id as string | undefined;
if (!oldId) { warnings.push(`Skipped ${p.originalName} — no id in frontmatter`); continue; }
const newIdVal = idRemap.get(oldId)!;
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)) {
// Parent isn't part of this export — pin to ROOT for safety.
newParent = ROOT_ID;
}
// Rewrite body: ![[basename]] -> ![[<attachmentsFolder>/basename]]
const rewrittenBody = rewriteImportedAttachmentLinks(p.body, attachmentsFolder);
const newFm: Record<string, any> = {
...p.fm,
id: newIdVal,
parent: newParent,
import_date: importDate,
};
if (Array.isArray(newFm.attachments)) {
newFm.attachments = (newFm.attachments as string[]).map((a) =>
`${attachmentsFolder}/${baseFileName(a)}`,
);
}
const finalContent = serializeNote(newFm, rewrittenBody);
// Filename: prefer original; if id changed, replace short id suffix; if collision on disk, suffix.
let outName = newIdVal === oldId ? p.originalName : remixFilename(p.originalName, oldId, newIdVal);
let outPath = `${destFolder}/${outName}`;
if (await app.vault.adapter.exists(outPath)) {
const stem = outName.replace(/\.md$/, "");
outName = `${stem}-${newId(4)}.md`;
outPath = `${destFolder}/${outName}`;
}
await app.vault.create(outPath, finalContent);
notesWritten++;
}
return { notesWritten, attachmentsWritten, collisionsRenamed, warnings };
}
// ---------------- Helpers ----------------
function dedupeById<T extends { id: string }>(items: T[]): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const it of items) {
if (seen.has(it.id)) continue;
seen.add(it.id);
out.push(it);
}
return out;
}
function extractAttachmentRefs(md: string): string[] {
const out = new Set<string>();
let m: RegExpExecArray | null;
ATTACHMENT_LINK_RE.lastIndex = 0;
while ((m = ATTACHMENT_LINK_RE.exec(md)) !== null) out.add(m[1]);
return [...out];
}
function rewriteAttachmentRef(md: string, oldRef: string, basename: string): string {
// Replace exactly inside ![[...]] occurrences only.
return md.replace(new RegExp(`!\\[\\[${escapeRegex(oldRef)}(\\|[^\\]]+)?\\]\\]`, "g"),
(_m, alias) => `![[${basename}${alias ?? ""}]]`);
}
function rewriteImportedAttachmentLinks(body: string, attachmentsFolder: string): string {
return body.replace(ATTACHMENT_LINK_RE, (match, ref: string, _aliasRaw) => {
// If ref already contains a slash, leave it alone (assume the importer wants a specific path).
if (ref.includes("/")) return match;
return match.replace(ref, `${attachmentsFolder}/${ref}`);
});
}
function rewriteFrontmatterAttachmentList(md: string, app: App, notePath: string): string {
const split = splitFrontmatter(md);
if (!split.fm.attachments || !Array.isArray(split.fm.attachments)) return md;
const remapped = (split.fm.attachments as string[]).map((a) => {
const af = app.metadataCache.getFirstLinkpathDest(a, notePath);
return af ? af.name : baseFileName(a);
});
const newFm = { ...split.fm, attachments: remapped };
return serializeNote(newFm, split.body);
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function baseFileName(p: string): string {
const i = p.lastIndexOf("/");
return i < 0 ? p : p.slice(i + 1);
}
function remixFilename(originalName: string, oldId: string, newId: string): string {
// Filenames look like "{slug}-{shortid}.md". Replace the trailing "-{oldId}" if present.
if (originalName.includes(oldId)) return originalName.replace(oldId, newId);
return originalName.replace(/\.md$/, `-${newId}.md`);
}
function splitFrontmatter(content: string): { fm: Record<string, any>; body: string } {
if (!content.startsWith("---")) return { fm: {}, body: content };
const end = content.indexOf("\n---", 3);
if (end < 0) return { fm: {}, body: content };
const yamlText = content.slice(3, end).replace(/^\n/, "");
const after = content.slice(end + 4);
let fm: Record<string, any> = {};
try { fm = (parseYaml(yamlText) as Record<string, any>) ?? {}; } catch { fm = {}; }
// Strip a leading newline from body if present.
const body = after.startsWith("\n") ? after.slice(1) : after;
return { fm, body };
}
function serializeNote(fm: Record<string, any>, body: string): string {
const yaml = stringifyYaml(fm).trimEnd();
return `---\n${yaml}\n---\n${body}`;
}
async function ensureFolder(app: App, path: string): Promise<void> {
if (!path) return;
const adapter = app.vault.adapter;
if (await adapter.exists(path)) return;
await app.vault.createFolder(path);
}

486
src/tree-index.ts Normal file
View file

@ -0,0 +1,486 @@
import { TFile, TFolder, type App, type CachedMetadata } from "obsidian";
import { ROOT_ID, type StashpadId, type TreeNode } from "./types";
/** Walk a Stashpad folder's TFolder subtree and return every .md file under
* it. Iterative DFS rather than recursive to avoid a deep-recursion blow-up
* if someone makes a pathological folder structure. Returns [] when the
* folder doesn't exist yet (first run, or the user just renamed the
* folder root). */
function collectMarkdown(app: App, folderPath: string): TFile[] {
const root = app.vault.getAbstractFileByPath(folderPath);
if (!(root instanceof TFolder)) return [];
const out: TFile[] = [];
const stack: TFolder[] = [root];
while (stack.length) {
const f = stack.pop()!;
for (const child of f.children) {
if (child instanceof TFile) {
if (child.extension === "md") out.push(child);
} else if (child instanceof TFolder) {
stack.push(child);
}
}
}
return out;
}
export class TreeIndex {
private nodes = new Map<StashpadId, TreeNode>();
private byPath = new Map<string, StashpadId>();
private listeners = new Set<() => void>();
private currentFolder: string | undefined;
/** Optional override for sibling order. Returns the explicit order array
* for a parent (empty if none); ids not in the array sort by created time. */
private orderProvider: ((parentId: StashpadId) => string[]) | null = null;
/** Synthetic-node carry-forward: nodes inserted via insertSynthetic() that haven't
* yet been claimed by metadataCache. Persisted across rebuilds until cache catches up. */
private synthetic = new Map<string /* path */, TreeNode>();
/** Coalesce timer for the metadata-cache hook so a burst of events triggers one rebuild. */
private coalesceTimer: number | null = null;
setOrderProvider(fn: ((parentId: StashpadId) => string[]) | null): void {
this.orderProvider = fn;
}
constructor(private app: App) {
this.nodes.set(ROOT_ID, {
id: ROOT_ID, parent: null, children: [], file: null, created: "",
});
}
onChange(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private emit(): void {
for (const fn of this.listeners) fn();
}
rebuild(folderPath?: string): void {
if (folderPath !== undefined) this.currentFolder = folderPath;
const folder = this.currentFolder;
// Snapshot the previous byPath/nodes so we can carry forward nodes whose
// frontmatter hasn't been parsed yet (synthetic insertions, slow network drives).
const prevByPath = this.byPath;
const prevNodes = this.nodes;
this.nodes = new Map();
this.byPath = new Map();
this.nodes.set(ROOT_ID, { id: ROOT_ID, parent: null, children: [], file: null, created: "" });
// Folder-scoped enumeration: walk only the Stashpad folder's TFolder
// subtree instead of `vault.getMarkdownFiles()` (which returns every
// markdown file in the vault and then string-prefix-filters). In a
// large vault with a small Stashpad this is a dramatic O(n_vault) →
// O(n_folder) reduction.
//
// We still allow a `!folder` mode (currentFolder never set) for safety;
// it falls back to the original vault-wide enumeration so the index
// works during the brief window before the view's bootstrap finishes.
const files: TFile[] = folder ? collectMarkdown(this.app, folder) : this.app.vault.getMarkdownFiles();
for (const f of files) {
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
const id = fm?.id as string | undefined;
// Carry-forward path: the file exists in the vault, but metadataCache hasn't
// parsed it yet (no id). If we previously had a node for this path (synthetic
// insert from createNoteUnder, or the previous rebuild), keep using it.
if (!id) {
const carriedId = prevByPath.get(f.path);
const carried = carriedId ? prevNodes.get(carriedId) : undefined;
const synthetic = this.synthetic.get(f.path);
const node = carried ?? synthetic;
if (node) {
this.nodes.set(node.id, { ...node, children: [], file: f });
this.byPath.set(f.path, node.id);
}
continue;
}
// Once the cache has the id, the synthetic mapping is no longer needed.
this.synthetic.delete(f.path);
if (id === ROOT_ID) {
const root = this.nodes.get(ROOT_ID)!;
root.file = f;
root.created = (fm?.created as string) ?? "";
this.byPath.set(f.path, ROOT_ID);
continue;
}
const parent = (fm?.parent as string | null | undefined) ?? null;
this.nodes.set(id, {
id,
parent: parent ?? ROOT_ID,
children: [],
file: f,
created: (fm?.created as string) ?? "",
});
this.byPath.set(f.path, id);
}
for (const node of this.nodes.values()) {
if (node.id === ROOT_ID) continue;
const parentId = node.parent ?? ROOT_ID;
const parent = this.nodes.get(parentId) ?? this.nodes.get(ROOT_ID)!;
parent.children.push(node.id);
}
for (const node of this.nodes.values()) {
// Default: sort by created (ascending — oldest first).
node.children.sort((a, b) => {
const na = this.nodes.get(a)!;
const nb = this.nodes.get(b)!;
return (na.created || "").localeCompare(nb.created || "");
});
// If an explicit order is provided for this parent, apply it: ids in the
// order array come first (in the array's order); ids not in it stay where
// they were (which is created-asc from the sort above).
if (this.orderProvider) {
const explicit = this.orderProvider(node.id);
if (explicit.length > 0) {
const positions = new Map<string, number>();
explicit.forEach((id, i) => positions.set(id, i));
node.children.sort((a, b) => {
const pa = positions.has(a) ? positions.get(a)! : Infinity;
const pb = positions.has(b) ? positions.get(b)! : Infinity;
if (pa === pb) return 0;
return pa - pb;
});
}
}
}
this.emit();
}
/** Insert a node into the tree without waiting for metadataCache to parse the file.
* Used by createNoteUnder so a freshly-written note appears in the list immediately
* on slow drives where the metadata cache parse is the bottleneck. */
insertSynthetic(node: TreeNode): void {
if (!node.file) return;
const path = node.file.path;
this.synthetic.set(path, node);
this.nodes.set(node.id, node);
this.byPath.set(path, node.id);
const parentId = node.parent ?? ROOT_ID;
const parent = this.nodes.get(parentId);
if (parent && !parent.children.includes(node.id)) {
parent.children.push(node.id);
}
this.emit();
}
get(id: StashpadId): TreeNode | undefined {
return this.nodes.get(id);
}
getRoot(): TreeNode {
return this.nodes.get(ROOT_ID)!;
}
getChildren(id: StashpadId): TreeNode[] {
const node = this.nodes.get(id);
if (!node) return [];
return node.children
.map((cid) => this.nodes.get(cid))
.filter((n): n is TreeNode => !!n);
}
pathTo(id: StashpadId): TreeNode[] {
const out: TreeNode[] = [];
let cur = this.nodes.get(id);
while (cur && cur.id !== ROOT_ID) {
out.unshift(cur);
cur = cur.parent ? this.nodes.get(cur.parent) : undefined;
}
return out;
}
idForPath(path: string): StashpadId | undefined {
return this.byPath.get(path);
}
snapshot(): Record<string, { parent: string | null; path: string }> {
const out: Record<string, { parent: string | null; path: string }> = {};
for (const n of this.nodes.values()) {
if (n.id === ROOT_ID || !n.file) continue;
out[n.id] = { parent: n.parent === ROOT_ID ? null : n.parent, path: n.file.path };
}
return out;
}
hookMetadataCache(onUpdate: () => void): () => void {
const pathInFolder = (p: string | null | undefined): boolean => {
const f = this.currentFolder;
if (!f) return true; // early startup — accept everything
if (typeof p !== "string") return false;
return p === f || p.startsWith(f + "/");
};
// Coalesce bursts: a single create can fire create + metadataCache.changed
// back-to-back, plus multiple processFrontMatter calls during a batch op.
// 16ms window collapses them into one onUpdate (render) call. Note the
// tree state itself is mutated synchronously by the apply* methods —
// only the visual update is debounced.
let dirty = false;
const scheduleUpdate = (): void => {
dirty = true;
if (this.coalesceTimer != null) return;
this.coalesceTimer = window.setTimeout(() => {
this.coalesceTimer = null;
if (!dirty) return;
dirty = false;
this.emit();
onUpdate();
}, 16);
};
const onChanged = (file: any): void => {
if (!(file instanceof TFile)) return;
if (!pathInFolder(file.path)) return;
if (file.extension !== "md") return;
if (this.applyChange(file)) scheduleUpdate();
};
const onCreate = (file: any): void => {
if (!(file instanceof TFile)) return;
if (!pathInFolder(file.path)) return;
if (file.extension !== "md") return;
if (this.applyChange(file)) scheduleUpdate();
};
const onDelete = (file: any): void => {
const path = file?.path;
if (typeof path !== "string") return;
if (!pathInFolder(path)) return;
if (this.applyDelete(path)) scheduleUpdate();
};
const onRename = (file: any, oldPath: any): void => {
if (!(file instanceof TFile)) return;
const oldP = typeof oldPath === "string" ? oldPath : null;
if (!pathInFolder(file.path) && !pathInFolder(oldP)) return;
if (this.applyRename(file, oldP ?? "")) scheduleUpdate();
};
(this.app.metadataCache as any).on("changed", onChanged);
(this.app.vault as any).on("create", onCreate);
(this.app.vault as any).on("delete", onDelete);
(this.app.vault as any).on("rename", onRename);
return () => {
(this.app.metadataCache as any).off("changed", onChanged);
(this.app.vault as any).off("create", onCreate);
(this.app.vault as any).off("delete", onDelete);
(this.app.vault as any).off("rename", onRename);
if (this.coalesceTimer != null) { window.clearTimeout(this.coalesceTimer); this.coalesceTimer = null; }
};
}
// ---------------------------------------------------------------------
// Incremental update path
//
// Before these existed, every vault event triggered a full rebuild —
// scanning the whole folder, allocating a fresh nodes/byPath Map, and
// re-sorting every parent's children. For a single-file change in a
// 500-note Stashpad that was O(500) work for an O(1) event.
//
// applyChange/applyDelete/applyRename instead patch the affected nodes
// and re-sort only the parents that actually moved. Safety net: if any
// case looks even slightly weird (unknown parent, id collision across
// paths, missing entries), we fall back to a full rebuild() — which is
// now folder-scoped and cheap. So the incremental path is always a
// pure optimization; bugs degrade to "full rebuild" rather than
// "stale tree."
//
// All three methods return `true` when the tree changed and an emit
// is warranted.
// ---------------------------------------------------------------------
/** Apply a single file create/modify event. Returns true if the tree
* changed (caller should schedule onUpdate). */
private applyChange(file: TFile): boolean {
const fm = this.app.metadataCache.getFileCache(file)?.frontmatter;
const id = fm?.id as string | undefined;
const oldId = this.byPath.get(file.path);
// Metadata cache hasn't parsed this file yet (no id in frontmatter).
// If we already have a synthetic / previous node for the path, just
// refresh its file reference; the metadata-cache "changed" event will
// fire again once the cache catches up.
if (!id) {
if (oldId) {
const node = this.nodes.get(oldId);
if (node && node.file !== file) {
node.file = file;
return true;
}
}
return false;
}
// Synthetic mapping no longer needed once the cache has the real id.
this.synthetic.delete(file.path);
// Self-declared root note. Update root metadata in place; root never
// has siblings to re-sort.
if (id === ROOT_ID) {
const root = this.nodes.get(ROOT_ID)!;
const created = (fm?.created as string) ?? "";
let changed = false;
if (root.file !== file) { root.file = file; changed = true; }
if (root.created !== created) { root.created = created; changed = true; }
if (this.byPath.get(file.path) !== ROOT_ID) {
this.byPath.set(file.path, ROOT_ID);
changed = true;
}
return changed;
}
const parentId = ((fm?.parent as string | null | undefined) ?? ROOT_ID) as StashpadId;
const created = (fm?.created as string) ?? "";
// Safety net: if the declared parent isn't ROOT and isn't in the
// tree yet (or our path<->id map is in a weird state), fall back to
// a full rebuild. The folder-scoped rebuild is cheap; correctness
// wins over a few hundred microseconds.
const parentKnown = parentId === ROOT_ID || this.nodes.has(parentId);
if (!parentKnown) {
this.rebuild();
return true;
}
// Detect path/id desync (e.g. someone rewrote the frontmatter id by
// hand): the path used to map to a different id. Cleanest recovery
// is a full rebuild.
if (oldId && oldId !== id) {
this.rebuild();
return true;
}
const existing = this.nodes.get(id);
if (!existing) {
// Brand-new node.
const node: TreeNode = { id, parent: parentId, children: [], file, created };
this.nodes.set(id, node);
this.byPath.set(file.path, id);
this.attachToParent(node);
this.resortChildrenOf(parentId);
return true;
}
// Existing node — diff fields and re-attach/re-sort only as needed.
let changed = false;
if (existing.file !== file) { existing.file = file; changed = true; }
if (existing.created !== created) {
existing.created = created;
this.resortChildrenOf(existing.parent ?? ROOT_ID);
changed = true;
}
if (existing.parent !== parentId) {
const oldParentId = existing.parent ?? ROOT_ID;
this.detachFromParent(existing);
existing.parent = parentId;
this.attachToParent(existing);
this.resortChildrenOf(oldParentId);
this.resortChildrenOf(parentId);
changed = true;
}
return changed;
}
/** Apply a file delete. Reassigns the deleted node's children to ROOT
* (mirroring the full-rebuild behavior when a parent file is missing). */
private applyDelete(path: string): boolean {
const id = this.byPath.get(path);
if (!id) return false;
const node = this.nodes.get(id);
if (!node) {
this.byPath.delete(path);
this.synthetic.delete(path);
return true;
}
this.detachFromParent(node);
// Re-parent any descendants to ROOT so they don't dangle. We don't
// touch their frontmatter — Stashpad's adoptNote / integrity-check
// command handles that side. This just keeps the in-memory tree
// consistent until the user runs a fixup.
const root = this.nodes.get(ROOT_ID)!;
for (const cid of node.children) {
const child = this.nodes.get(cid);
if (child) {
child.parent = ROOT_ID;
if (!root.children.includes(cid)) root.children.push(cid);
}
}
if (node.children.length > 0) this.resortChildrenOf(ROOT_ID);
this.nodes.delete(id);
this.byPath.delete(path);
this.synthetic.delete(path);
return true;
}
/** Apply a vault rename. Three cases:
* - moved out of folder treat as delete
* - moved into folder treat as create (delegated to applyChange)
* - renamed within folder just remap byPath; frontmatter unaffected */
private applyRename(file: TFile, oldPath: string): boolean {
const folder = this.currentFolder;
const wasIn = !folder || oldPath === folder || oldPath.startsWith(folder + "/");
const isIn = !folder || file.path === folder || file.path.startsWith(folder + "/");
if (!wasIn && !isIn) return false;
if (wasIn && !isIn) return this.applyDelete(oldPath);
if (!wasIn && isIn) return this.applyChange(file);
// Rename within folder.
const id = this.byPath.get(oldPath);
if (!id) {
// We never had this path indexed — fall through to a normal apply.
return this.applyChange(file);
}
this.byPath.delete(oldPath);
this.byPath.set(file.path, id);
const node = this.nodes.get(id);
if (node) node.file = file;
return true;
}
// --- attach / detach / sort helpers ---
private detachFromParent(node: TreeNode): void {
const parent = this.nodes.get(node.parent ?? ROOT_ID);
if (!parent) return;
const i = parent.children.indexOf(node.id);
if (i >= 0) parent.children.splice(i, 1);
}
private attachToParent(node: TreeNode): void {
// If the declared parent doesn't exist in the tree, fall back to ROOT
// (same orphan-handling as rebuild()'s second pass).
let parent = this.nodes.get(node.parent ?? ROOT_ID);
if (!parent) parent = this.nodes.get(ROOT_ID)!;
if (!parent.children.includes(node.id)) parent.children.push(node.id);
}
/** Re-sort a single parent's children using the same default + orderProvider
* pipeline as rebuild(). Pulled out so incremental updates can re-sort
* exactly one parent instead of every parent in the tree. */
private resortChildrenOf(parentId: StashpadId): void {
const p = this.nodes.get(parentId);
if (!p) return;
p.children.sort((a, b) => {
const na = this.nodes.get(a)!;
const nb = this.nodes.get(b)!;
return (na.created || "").localeCompare(nb.created || "");
});
if (this.orderProvider) {
const explicit = this.orderProvider(parentId);
if (explicit.length > 0) {
const positions = new Map<string, number>();
explicit.forEach((id, i) => positions.set(id, i));
p.children.sort((a, b) => {
const pa = positions.has(a) ? positions.get(a)! : Infinity;
const pb = positions.has(b) ? positions.get(b)! : Infinity;
if (pa === pb) return 0;
return pa - pb;
});
}
}
}
}

63
src/types.ts Normal file
View file

@ -0,0 +1,63 @@
import type { TFile } from "obsidian";
export const STASHPAD_VIEW_TYPE = "stashpad-view";
export const ROOT_ID = "__root__";
export type StashpadId = string;
export interface NoteFrontmatter {
id: StashpadId;
parent: StashpadId | null;
created: string;
attachments?: string[];
tags?: string[];
/** Optional hex color (e.g. "#E07A78") that tints the row's swatch, border,
* and child-count arrow. Stored verbatim in frontmatter. */
color?: string | null;
}
export interface TreeNode {
id: StashpadId;
parent: StashpadId | null;
children: StashpadId[];
file: TFile | null;
created: string;
}
export type TimeFilter = "all" | "year" | "month" | "week" | "day";
/** Per-folder view mode (settings.viewModes keyed by folder path).
* - "nested" (default): tree, immediate children of focus.
* - "flat": all descendants of focus, flat, sorted by the current sort mode.
* - "everything": all descendants of focus PLUS every non-Stashpad file in
* the Stashpad folder, interleaved by created/ctime. Non-Stashpad files
* are always folder-wide (they don't belong to any note).
*
* Drag-reorder and tree-mutation commands only operate in "nested" mode;
* in flat/everything the list is a synthesized view and direct
* position/parent changes would have no meaningful target. */
export type ViewMode = "nested" | "flat" | "everything";
export interface ViewConfigState {
focusId: StashpadId;
timeFilter: TimeFilter;
}
export type LogEventType =
| "create" | "delete" | "missing" | "parent_change" | "rename" | "reorder"
| "complete" | "uncomplete"
| "stash_export" | "stash_import"
| "attachment_add" | "attachment_remove"
| "palette_color_add" | "palette_color_remove";
export interface LogEvent {
ts: string;
type: LogEventType;
id: StashpadId;
payload?: Record<string, unknown>;
/** Display name of whoever performed the action. Stamped automatically
* by StashpadLog.append() from the plugin's authorName setting; older
* log lines may lack this field, in which case readers should treat
* it as unknown. */
author?: string;
}

39
src/undo-stack.ts Normal file
View file

@ -0,0 +1,39 @@
export interface UndoAction {
label: string;
undo: () => Promise<void>;
redo: () => Promise<void>;
}
export class UndoStack {
private undoStack: UndoAction[] = [];
private redoStack: UndoAction[] = [];
private cap = 30;
push(action: UndoAction): void {
this.undoStack.push(action);
this.redoStack.length = 0;
while (this.undoStack.length > this.cap) this.undoStack.shift();
}
async undo(): Promise<UndoAction | null> {
const a = this.undoStack.pop();
if (!a) return null;
try { await a.undo(); } catch (e) { console.error("Stashpad: undo failed", e); throw e; }
this.redoStack.push(a);
return a;
}
async redo(): Promise<UndoAction | null> {
const a = this.redoStack.pop();
if (!a) return null;
try { await a.redo(); } catch (e) { console.error("Stashpad: redo failed", e); throw e; }
this.undoStack.push(a);
return a;
}
peekUndoLabel(): string | null { return this.undoStack[this.undoStack.length - 1]?.label ?? null; }
peekRedoLabel(): string | null { return this.redoStack[this.redoStack.length - 1]?.label ?? null; }
canUndo(): boolean { return this.undoStack.length > 0; }
canRedo(): boolean { return this.redoStack.length > 0; }
clear(): void { this.undoStack = []; this.redoStack = []; }
}

6386
src/view.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -328,6 +328,11 @@
border-color: var(--interactive-accent);
color: var(--interactive-accent);
}
.stashpad-sort-btn.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.stashpad-sort-btn.is-disabled:hover { background: var(--background-secondary); }
.stashpad-sort-icon { display: inline-flex; align-items: center; }
.stashpad-sort-icon .svg-icon { width: 14px; height: 14px; }
.stashpad-sort-label {
@ -367,6 +372,288 @@
}
.stashpad-sort-popover-label { white-space: nowrap; }
/* View dropdown same shape as Sort but each row is two-line (label + a
short description) so the three modes read clearly at a glance. */
.stashpad-view-btn {
flex: 0 0 auto;
width: max-content;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 6px;
background: var(--background-secondary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
font-size: var(--font-ui-smaller);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.stashpad-view-btn:hover { background: var(--background-modifier-hover); }
.stashpad-view-btn.is-active {
border-color: var(--interactive-accent);
color: var(--interactive-accent);
}
.stashpad-view-icon { display: inline-flex; align-items: center; }
.stashpad-view-icon .svg-icon { width: 14px; height: 14px; }
.stashpad-view-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stashpad-view.is-mobile .stashpad-view-btn {
height: 28px;
padding: 0 6px;
}
.stashpad-view-popover {
position: fixed;
z-index: var(--layer-popover, 30);
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
padding: 4px 0;
font-size: var(--font-ui-small);
/* No max-height the menu always expands fully so the user can see
every mode + filter at once. overflow-y is a no-op without a
height constraint, but kept declared as a safety net for any
future case where the popover needs to clamp (e.g. tiny popout
window on desktop). */
overflow-y: auto;
}
/* Mobile: hide per-row descriptions to keep popovers compact. Applies
to both the standalone view popover (.stashpad-view-popover.is-mobile)
AND the body of any section rendered inside the combined-filters
accordion (.stashpad-mobile-filters-popover wraps every section). */
.stashpad-view-popover.is-mobile .stashpad-view-popover-desc,
.stashpad-mobile-filters-popover .stashpad-view-popover-desc {
display: none;
}
.stashpad-view-popover.is-mobile .stashpad-view-popover-row,
.stashpad-mobile-filters-popover .stashpad-view-popover-row {
padding: 6px 12px;
}
.stashpad-view-popover-row {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px;
cursor: pointer;
}
.stashpad-view-popover-row:hover { background: var(--background-modifier-hover); }
.stashpad-view-popover-row.is-active .stashpad-view-popover-label {
color: var(--interactive-accent);
font-weight: 600;
}
.stashpad-view-popover-main { display: flex; align-items: center; gap: 8px; }
.stashpad-view-popover-label { white-space: nowrap; }
.stashpad-view-popover-desc {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
white-space: normal;
max-width: 280px;
}
.stashpad-view-popover-divider {
height: 1px;
background: var(--background-modifier-border);
margin: 4px 0;
}
/* Toggle row: 2-column grid keeps the checkbox aligned with the label
in the first row and the description tucked under the label in the
second row. Cleaner than the previous flex-wrap which depended on
browser-default checkbox widths matching the desc's left padding. */
.stashpad-view-popover-toggle {
display: grid;
grid-template-columns: auto 1fr;
column-gap: 8px;
row-gap: 2px;
align-items: center;
padding: 8px 12px;
}
.stashpad-view-popover-toggle input[type="checkbox"] {
/* Use transform: scale() instead of explicit width/height to shrink
the checkbox visually. Chromium draws the native checkmark glyph
at fixed pixel sizes; clamping the box smaller crops the glyph and
visually decentres the check. Scaling lets the glyph shrink in
proportion with the box so the check stays centred. transform-origin
left keeps the small box flush with the left edge of the grid
column. */
margin: 0;
transform: scale(0.85);
transform-origin: left center;
grid-row: 1;
grid-column: 1;
justify-self: start;
cursor: pointer;
}
.stashpad-view-popover-toggle .stashpad-view-popover-main {
grid-row: 1;
grid-column: 2;
}
.stashpad-view-popover-toggle .stashpad-view-popover-desc {
grid-row: 2;
grid-column: 2;
padding-left: 0;
}
.stashpad-view-popover-toggle.is-disabled {
opacity: 0.55;
cursor: default;
}
/* Mobile combined-filters button: a single sliders icon that replaces
the four desktop dropdown buttons (tag/color/sort/view) on mobile.
Picks up an accent border when any of the underlying filters / view
options are in a non-default state. */
.stashpad-mobile-filters-btn {
flex: 0 0 auto;
/* Push to the far right of the header bar auto-margin in a flex
row absorbs all remaining space on the left of this element, so
the button sits hard against the right edge regardless of what
else (folder button, etc.) is rendered to its left. */
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 32px;
padding: 0;
border-radius: 6px;
background: var(--background-secondary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
cursor: pointer;
user-select: none;
}
.stashpad-mobile-filters-btn:hover { background: var(--background-modifier-hover); }
.stashpad-mobile-filters-btn.is-active {
border-color: var(--interactive-accent);
color: var(--interactive-accent);
}
.stashpad-mobile-filters-icon { display: inline-flex; align-items: center; }
.stashpad-mobile-filters-icon .svg-icon { width: 16px; height: 16px; }
/* Accordion popover. Four sections stacked vertically; one expanded at
a time. Header chevron flips between right (collapsed) and down
(expanded). Bodies host the same row markup the desktop popovers
use see populate*MenuBody methods. */
.stashpad-mobile-filters-popover {
position: fixed;
z-index: var(--layer-popover, 30);
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
padding: 4px 0;
font-size: var(--font-ui-small);
overflow-y: auto;
/* Cap height so a fully expanded section with many rows can scroll
instead of clipping below the viewport. */
max-height: calc(100vh - 80px);
}
.stashpad-mobile-filters-section + .stashpad-mobile-filters-section {
border-top: 1px solid var(--background-modifier-border);
}
.stashpad-mobile-filters-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
cursor: pointer;
user-select: none;
}
.stashpad-mobile-filters-header:hover { background: var(--background-modifier-hover); }
.stashpad-mobile-filters-chev {
display: inline-flex;
align-items: center;
color: var(--text-muted);
}
.stashpad-mobile-filters-chev .svg-icon { width: 14px; height: 14px; }
.stashpad-mobile-filters-title { font-weight: 600; }
.stashpad-mobile-filters-summary {
margin-left: auto;
color: var(--text-faint);
font-size: var(--font-ui-smaller);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 50%;
}
.stashpad-mobile-filters-body {
padding: 2px 0 6px;
}
.stashpad-mobile-filters-note {
padding: 8px 16px;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}
/* Per-row breadcrumb shown in Flat / Everything modes. Sits above the
note body, muted by default so it doesn't compete with the body text.
Segments are clickable to focus into that ancestor. */
.stashpad-row-breadcrumb {
font-size: var(--font-ui-smaller);
color: var(--text-faint);
margin-bottom: 4px;
line-height: 1.2;
}
.stashpad-row-breadcrumb-seg {
cursor: pointer;
}
.stashpad-row-breadcrumb-seg:hover {
color: var(--text-normal);
text-decoration: underline;
}
.stashpad-row-breadcrumb-sep {
opacity: 0.6;
pointer-events: none;
}
/* Non-Stashpad file row in Everything mode intentionally compact and
visually distinct from note rows so users see it's a different kind
of item (decorative click-to-open, not part of the selection model). */
.stashpad-file-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-left: 3px solid transparent;
cursor: pointer;
font-size: var(--font-ui-small);
color: var(--text-muted);
}
.stashpad-file-row:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.stashpad-file-meta {
flex: 0 0 auto;
min-width: 70px;
}
.stashpad-file-time {
font-variant-numeric: tabular-nums;
color: var(--text-faint);
}
.stashpad-file-body {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.stashpad-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stashpad-file-ext {
flex: 0 0 auto;
font-size: var(--font-ui-smaller);
padding: 1px 6px;
border-radius: 3px;
background: var(--background-modifier-border);
color: var(--text-muted);
letter-spacing: 0.05em;
}
.stashpad-color-filter-popover {
position: fixed;
z-index: var(--layer-popover, 30);
@ -394,8 +681,11 @@
.stashpad-time-filter-btns { display: none; }
.stashpad-time-filter-select { display: block; }
}
.stashpad-view.is-mobile .stashpad-time-filter-btns { display: none; }
.stashpad-view.is-mobile .stashpad-time-filter-select { display: block; }
/* Mobile: the time filter moves into the combined-filters accordion.
Hide both the button row AND the fallback select here the accordion's
Time section provides the UI. */
.stashpad-view.is-mobile .stashpad-time-filter-btns,
.stashpad-view.is-mobile .stashpad-time-filter-select { display: none; }
.stashpad-breadcrumb {
font-size: var(--font-ui-small);
@ -568,6 +858,32 @@
.stashpad-note.has-color {
border-color: var(--stashpad-note-color);
}
/* Inherited-color side stripe: rendered on rows whose nearest colored
ancestor is some depth above them. A short pseudo-element down the
left edge tinted with the ancestor's hex, faded by depth (~18% off
per level) via color-mix. Visually marks "this row belongs to a
colored branch" without overwhelming the row's own chrome. Hidden
beneath the row's normal border by being inset 1px from the edge.
color-mix is supported on Electron 113+ (current Obsidian). */
.stashpad-note.has-inherited-color {
position: relative;
}
.stashpad-note.has-inherited-color::before {
content: "";
position: absolute;
left: -1px;
top: 4px;
bottom: 4px;
width: 3px;
border-radius: 2px;
background: color-mix(
in srgb,
var(--stashpad-inherited-color) calc(100% - var(--stashpad-inherited-depth, 0) * 18%),
transparent
);
pointer-events: none;
}
/* When the row is selected, the colored border thickens so the selection
state reads as a single, unified ring no double-line from the default
selected accent or the is-cursor inset shadow. */

15
tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "Bundler",
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["DOM", "ES2020"],
"types": ["node"]
},
"include": ["src/**/*.ts"]
}