diff --git a/.eslintrc.json b/.eslintrc.json index 83479e8..d098d31 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -9,7 +9,15 @@ "@typescript-eslint" ], "rules": { - "@typescript-eslint/class-name-casing": "warn", + "@typescript-eslint/naming-convention": [ + "warn", + { + "selector": "class", + "format": [ + "PascalCase" + ] + } + ], "@typescript-eslint/semi": "warn", "curly": "warn", "eqeqeq": "warn", diff --git a/CHANGELOG.md b/CHANGELOG.md index 70383f0..cca6d1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* the Notes view now refreshes on its own when notes are added, renamed or removed outside of the window, by a sync client or another editor. Open folders are checked on an interval, which is what covers synced and Windows folders where no file system event ever arrives. Two new settings: `notes.watchExternalChanges` and `notes.watchIntervalSeconds`. +* `Reveal in File Explorer` on the right click menu of a note or a folder, and on the view menu for the storage location itself +* `notes.storeLocationInWorkspace`, on by default, to choose whether a storage location you pick is saved in the workspace settings or in your own editor settings. Turning it off keeps a personal path out of a `.vscode/settings.json` that may be committed. + +### Changed + +* changing the storage location or the allowed extensions now updates the Notes view right away, a window reload is no longer required + +### Fixed + +* the Notes view is now refreshed once a note or folder has actually been created or deleted, instead of before the write completes +* notes and folders added or removed outside of the extension now show up in the Notes view without a reload + ## [2.0.0] - 2025-03-26 ### Added diff --git a/package-lock.json b/package-lock.json index c12bade..cbc2903 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-notes", - "version": "1.2.3", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-notes", - "version": "1.2.3", + "version": "2.0.0", "license": "CC-BY-NC-4.0", "dependencies": { "vscode-uri": "^3.0.8" @@ -20,12 +20,12 @@ "@types/vscode": "^1.87.0", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", + "@vscode/test-electron": "^2.5.2", "eslint": "^8.56.0", "glob": "^10.3.10", "mocha": "^10.2.0", "ts-loader": "^9.5.1", "typescript": "^5.3.3", - "vscode-test": "^1.6.1", "webpack": "^5.89.0", "webpack-cli": "^5.1.4" }, @@ -329,16 +329,6 @@ "node": ">=14" } }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -665,6 +655,61 @@ "dev": true, "license": "ISC" }, + "node_modules/@vscode/test-electron": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", + "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "jszip": "^3.10.1", + "ora": "^8.1.0", + "semver": "^7.6.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@vscode/test-electron/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@vscode/test-electron/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@vscode/test-electron/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -910,19 +955,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1052,30 +1084,6 @@ "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true }, - "node_modules/big-integer": { - "version": "1.6.52", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", - "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "dev": true, - "license": "Unlicense", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/binary": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", - "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffers": "~0.1.1", - "chainsaw": "~0.1.0" - }, - "engines": { - "node": "*" - } - }, "node_modules/binary-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz", @@ -1085,13 +1093,6 @@ "node": ">=8" } }, - "node_modules/bluebird": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", - "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", - "dev": true, - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1161,25 +1162,6 @@ "dev": true, "license": "MIT" }, - "node_modules/buffer-indexof-polyfill": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", - "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/buffers": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", - "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", - "dev": true, - "engines": { - "node": ">=0.2.0" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1211,19 +1193,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chainsaw": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", - "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", - "dev": true, - "license": "MIT/X11", - "dependencies": { - "traverse": ">=0.3.0 <0.4" - }, - "engines": { - "node": "*" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1280,6 +1249,35 @@ "node": ">=6.0" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -1418,16 +1416,6 @@ "node": ">=6.0.0" } }, - "node_modules/duplexer2": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", - "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "readable-stream": "^2.0.2" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1865,59 +1853,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/fstream": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", - "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", - "deprecated": "This package is no longer supported.", - "dev": true, - "license": "ISC", - "dependencies": { - "graceful-fs": "^4.1.2", - "inherits": "~2.0.0", - "mkdirp": ">=0.5 0", - "rimraf": "2" - }, - "engines": { - "node": ">=0.6" - } - }, - "node_modules/fstream/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fstream/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1937,6 +1872,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -2086,35 +2034,6 @@ "he": "bin/he" } }, - "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2125,6 +2044,13 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -2259,6 +2185,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2424,6 +2363,19 @@ "dev": true, "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==", + "dev": true, + "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/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2458,12 +2410,15 @@ "node": ">= 0.8.0" } }, - "node_modules/listenercount": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", - "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", "dev": true, - "license": "ISC" + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } }, "node_modules/loader-runner": { "version": "4.3.0", @@ -2575,6 +2530,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2815,6 +2783,22 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2833,6 +2817,140 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -2882,6 +3000,13 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3218,6 +3343,23 @@ "node": ">=4" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -3475,6 +3617,19 @@ "node": ">=0.10.0" } }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -3664,16 +3819,6 @@ "node": ">=8.0" } }, - "node_modules/traverse": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", - "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", - "dev": true, - "license": "MIT/X11", - "engines": { - "node": "*" - } - }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -3761,25 +3906,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unzipper": { - "version": "0.10.14", - "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", - "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "big-integer": "^1.6.17", - "binary": "~0.3.0", - "bluebird": "~3.4.1", - "buffer-indexof-polyfill": "~1.0.0", - "duplexer2": "~0.1.4", - "fstream": "^1.0.12", - "graceful-fs": "^4.2.2", - "listenercount": "~1.0.1", - "readable-stream": "~2.3.6", - "setimmediate": "~1.0.4" - } - }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -3827,23 +3953,6 @@ "dev": true, "license": "MIT" }, - "node_modules/vscode-test": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/vscode-test/-/vscode-test-1.6.1.tgz", - "integrity": "sha512-086q88T2ca1k95mUzffvbzb7esqQNvJgiwY4h29ukPhFo8u+vXOOmelUoU5EQUHs3Of8+JuQ3oGdbVCqaxuTXA==", - "deprecated": "This package has been renamed to @vscode/test-electron, please update to the new name", - "dev": true, - "license": "MIT", - "dependencies": { - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "rimraf": "^3.0.2", - "unzipper": "^0.10.11" - }, - "engines": { - "node": ">=8.9.3" - } - }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", diff --git a/package.json b/package.json index 807f05c..ecc2061 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,29 @@ "type": "string", "default": "*", "description": "A comma separated list of allowed extensions for notes. Use '*' (asterisk) to allow all file types. (do not include the dot or spaces)" + }, + "notes.storeLocationInWorkspace": { + "type": "boolean", + "default": true, + "scope": "application", + "description": "Save the storage location you pick in the settings of the workspace, in its .vscode/settings.json, so a folder of notes can travel with the project. Turn this off to keep the location in your own editor settings instead, and out of a file that may be committed. This setting is yours rather than a workspace's, so no repository can change it." + }, + "notes.watchExternalChanges": { + "type": "boolean", + "default": true, + "description": "Refresh the Notes view when notes are added, renamed or removed outside of this window, by a sync client or another editor for example." + }, + "notes.watchIntervalSeconds": { + "type": "number", + "enum": [ + 2, + 3, + 5, + 10, + 30 + ], + "default": 5, + "description": "How often, in seconds, to check the open folders for external changes. File system events are used when they are available, but a storage location on a synced or Windows folder never sends any, and this check is what covers it." } } }, @@ -108,6 +131,11 @@ "dark": "resources/dark/rename.svg" } }, + { + "command": "Notes.revealInOS", + "title": "Reveal in File Explorer", + "category": "Notes" + }, { "command": "Notes.setupNotes", "title": "Settings", @@ -148,6 +176,11 @@ "command": "Notes.setupNotes", "when": "view == notes", "group": "navigation@4" + }, + { + "command": "Notes.revealInOS", + "when": "view == notes", + "group": "1_open@1" } ], "view/item/context": [ @@ -171,6 +204,16 @@ "when": "view == notes && viewItem == folder", "group": "1_modification@2" }, + { + "command": "Notes.revealInOS", + "when": "view == notes && viewItem == note", + "group": "3_open@1" + }, + { + "command": "Notes.revealInOS", + "when": "view == notes && viewItem == folder", + "group": "3_open@1" + }, { "command": "Notes.newNote", "when": "view == notes && viewItem == folder", @@ -195,6 +238,11 @@ "command": "Notes.refreshNotes", "when": "view == notes && !viewItem", "group": "navigation@3" + }, + { + "command": "Notes.revealInOS", + "when": "view == notes && !viewItem", + "group": "3_open@1" } ] }, @@ -242,16 +290,16 @@ "@types/vscode": "^1.87.0", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", + "@vscode/test-electron": "^2.5.2", "eslint": "^8.56.0", "glob": "^10.3.10", "mocha": "^10.2.0", "ts-loader": "^9.5.1", "typescript": "^5.3.3", - "vscode-test": "^1.6.1", "webpack": "^5.89.0", "webpack-cli": "^5.1.4" }, "dependencies": { "vscode-uri": "^3.0.8" } -} \ No newline at end of file +} diff --git a/src/extension.ts b/src/extension.ts index 892c2ce..5cfe400 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,9 +1,10 @@ -// figure out how to reload treeview when notes location changes import * as vscode from 'vscode'; +import * as cp from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { Note } from './note'; import { NotesViewProvider } from './notesViewProvider'; +import { initNotesWatcher, rebaselineWatcher, restartNotesWatcher, setNotesVisible } from './notesWatcher'; let extId = 'vscode-notes'; let extPub = 'dionmunk'; @@ -15,23 +16,42 @@ export function activate(context: vscode.ExtensionContext) { // get Notes configuration let notesTree = new NotesViewProvider(String(Notes.getNotesLocation()), String(Notes.getNotesExtensions())); - vscode.window.registerTreeDataProvider('notes', notesTree.init()); + // a tree view rather than a plain provider, the watcher needs to know which folders are open + let notesView = vscode.window.createTreeView('notes', { treeDataProvider: notesTree.init() }); + context.subscriptions.push(notesView); + + context.subscriptions.push( + // an opened folder joins what is watched, a closed one leaves it + notesView.onDidExpandElement(e => { + notesTree.setExpanded(e.element, true); + rebaselineWatcher(); + }), + notesView.onDidCollapseElement(e => { + notesTree.setExpanded(e.element, false); + rebaselineWatcher(); + }), + // nothing to poll for while the view is hidden + notesView.onDidChangeVisibility(e => setNotesVisible(e.visible)), + // the tree was just rebuilt, take what is on disk now as the new reference + notesTree.onDidChangeTreeData(() => rebaselineWatcher()) + ); + + // keep the tree in sync with changes made outside of this window + initNotesWatcher(context, notesTree); // Listen for configuration changes context.subscriptions.push( vscode.workspace.onDidChangeConfiguration(e => { - // Check if notes.notesLocation setting changed - if (e.affectsConfiguration('notes.notesLocation')) { - // Prompt to reload window so storage location change can take effect - vscode.window.showWarningMessage( - `The Notes extension detected a change in the storage location. You must reload the window for the change to take effect.`, - 'Reload' - ).then(selectedAction => { - // if the user selected to reload the window then reload - if (selectedAction === 'Reload') { - vscode.commands.executeCommand('workbench.action.reloadWindow'); - } - }); + // Check if a setting that affects the tree view changed + if (e.affectsConfiguration('notes.notesLocation') || e.affectsConfiguration('notes.notesExtensions')) { + // apply the change to the tree view directly, no window reload needed + notesTree.updateConfiguration(String(Notes.getNotesLocation()), String(Notes.getNotesExtensions())); + } + // point the watcher at the new location, or apply the new watch settings + if (e.affectsConfiguration('notes.notesLocation') + || e.affectsConfiguration('notes.watchExternalChanges') + || e.affectsConfiguration('notes.watchIntervalSeconds')) { + restartNotesWatcher(); } }) ); @@ -94,9 +114,15 @@ export function activate(context: vscode.ExtensionContext) { }); context.subscriptions.push(renameFolderDisposable); + // reveal in the OS file explorer + let revealInOSDisposable = vscode.commands.registerCommand('Notes.revealInOS', (item?: Note) => { + Notes.revealInOS(item); + }); + context.subscriptions.push(revealInOSDisposable); + // setup notes let setupNotesDisposable = vscode.commands.registerCommand('Notes.setupNotes', () => { - Notes.setupNotes(); + Notes.setupNotes(notesTree); }); context.subscriptions.push(setupNotesDisposable); @@ -131,6 +157,24 @@ export class Notes { return vscode.workspace.getConfiguration('notes').get('notesExtensions'); } + /* + * Where a newly picked storage location is written. + * + * The workspace keeps it in .vscode/settings.json, which is convenient for a + * folder of notes that belongs with the project, but it is also a file that + * usually ends up committed, carrying a personal path with it. Turning the + * setting off keeps the location in the editor's own settings instead. + */ + static getLocationTarget(): vscode.ConfigurationTarget { + const storeInWorkspace = vscode.workspace.getConfiguration('notes').get('storeLocationInWorkspace', true); + // there is nothing to write a workspace setting into without a workspace + const hasWorkspace = (vscode.workspace.workspaceFolders?.length ?? 0) > 0; + + return storeInWorkspace && hasWorkspace + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global; + } + // delete note static deleteNote(note: Note, tree: NotesViewProvider): void { // prompt user for confirmation @@ -147,9 +191,10 @@ export class Notes { } // else let the user know the file was deleted successfully vscode.window.showInformationMessage(`Successfully deleted ${note.name}.`); + + // refresh tree after deleting note + tree.refresh(); }); - // refresh tree after deleting note - tree.refresh(); } }); } @@ -243,6 +288,9 @@ export class Notes { return vscode.window.showErrorMessage('Failed to create the new note.'); } else { + // refresh tree after creating new note + tree.refresh(); + // open file let file = vscode.Uri.file(filePath); vscode.window.showTextDocument(file).then(() => { @@ -251,8 +299,6 @@ export class Notes { }); } }); - // refresh tree after creating new note - tree.refresh(); } else { // report @@ -292,10 +338,11 @@ export class Notes { } else { vscode.window.showInformationMessage(`Successfully created folder ${folderName}.`); + + // refresh tree after creating new folder + tree.refresh(); } }); - // refresh tree after creating new folder - tree.refresh(); } else { // report @@ -328,6 +375,54 @@ export class Notes { vscode.window.showTextDocument(vscode.Uri.file(filePath)); } + // reveal a note or folder in the file explorer of the operating system + static revealInOS(item?: Note): void { + // the view title action carries no note, fall back to the storage location + let target = item?.fullPath ?? String(Notes.getNotesLocation()); + // a folder is opened, a note is revealed inside the folder holding it + let isFolder = item ? item.isFolder : true; + + if (!target) { + vscode.window.showWarningMessage('No storage location has been selected yet.'); + return; + } + + /* + * On WSL the extension runs on the Linux side while the file explorer is + * the Windows one. The built-in command would hand it a path like + * /mnt/c/Users/… , which means nothing to Windows, so the path is + * translated and Explorer called directly. + */ + if (vscode.env.remoteName === 'wsl') { + Notes.revealInWindowsExplorer(target, isFolder); + return; + } + + vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(target)).then(undefined, err => { + console.error(err); + vscode.window.showErrorMessage(`Failed to reveal '${target}' in the file explorer.`); + }); + } + + // reveal a path in the Windows file explorer, from the Linux side of WSL + private static revealInWindowsExplorer(target: string, isFolder: boolean): void { + cp.execFile('wslpath', ['-w', target], (err, stdout) => { + if (err) { + console.error(err); + vscode.window.showErrorMessage(`Failed to translate '${target}' to a Windows path.`); + return; + } + + let windowsPath = stdout.trim(); + // /select, takes its path in the same argument, and reveals rather than opens + let argument = isFolder ? windowsPath : `/select,${windowsPath}`; + + // Explorer reports a non zero exit code even when it worked, so there is + // nothing here worth reporting to the user + cp.execFile('explorer.exe', [argument], () => { }); + }); + } + // refresh notes static refreshNotes(tree: NotesViewProvider): void { // refresh tree @@ -450,18 +545,18 @@ export class Notes { if (fileUri && fileUri[0]) { // get Notes configuration let notesConfiguration = vscode.workspace.getConfiguration('notes'); + // set the selected location + let selectedLocation = path.normalize(fileUri[0].fsPath); + // where the setting is written, the workspace or the editor itself + let target = Notes.getLocationTarget(); + // update Notes configuration with selected location - notesConfiguration.update('notesLocation', path.normalize(fileUri[0].fsPath), true).then(() => { - // prompt to reload window so storage location change can take effect - vscode.window.showWarningMessage( - `The Notes extension detected a change in the storage location. You must reload the window for the change to take effect.`, - 'Reload' - ).then(selectedAction => { - // if the user selected to reload the window then reload - if (selectedAction === 'Reload') { - vscode.commands.executeCommand('workbench.action.reloadWindow'); - } - }); + notesConfiguration.update('notesLocation', selectedLocation, target).then(() => { + // apply the new location right away, no window reload needed + tree?.updateConfiguration(selectedLocation, String(Notes.getNotesExtensions())); + + let savedIn = target === vscode.ConfigurationTarget.Workspace ? 'workspace' : 'user'; + vscode.window.showInformationMessage(`Notes are now stored in '${selectedLocation}', saved in the ${savedIn} settings.`); }); } }); diff --git a/src/note.ts b/src/note.ts index daaad4b..edf7f16 100644 --- a/src/note.ts +++ b/src/note.ts @@ -21,6 +21,10 @@ export class Note extends vscode.TreeItem { this.isFolder = isDirectory; this.fullPath = path.join(location, name); + // a stable id lets VS Code keep folders expanded and items selected across + // a refresh, which an auto-refreshing tree does behind the user's back + this.id = this.fullPath; + // Set appropriate icon based on whether this is a folder or file if (isDirectory) { // Use VS Code's built-in folder icons diff --git a/src/notesViewProvider.ts b/src/notesViewProvider.ts index c40105d..c07b7d9 100644 --- a/src/notesViewProvider.ts +++ b/src/notesViewProvider.ts @@ -9,6 +9,7 @@ export class NotesViewProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; private folderMap: Map = new Map(); + private expandedFolders: Set = new Set(); // constructor for NotesViewProvider constructor( @@ -22,6 +23,102 @@ export class NotesViewProvider implements vscode.TreeDataProvider { return this; } + // apply a new notes location and/or extension list without a window reload + public updateConfiguration(notesLocation: string, notesExtensions: string): void { + // if nothing actually changed there is nothing to do + if (this.notesLocation === notesLocation && this.notesExtensions === notesExtensions) { + return; + } + + this.notesLocation = notesLocation; + this.notesExtensions = notesExtensions; + + // drop any cached folder contents from the previous location + this.folderMap.clear(); + // the folders of the previous location are gone, expanded or not + this.expandedFolders.clear(); + + // refresh the tree so the new configuration takes effect + this.refresh(); + } + + // remember which folders are open, they are the ones worth watching + public setExpanded(note: Note, expanded: boolean): void { + if (expanded) { + this.expandedFolders.add(note.fullPath); + } else { + this.expandedFolders.delete(note.fullPath); + } + } + + /* + * Signature of everything the tree currently displays. + * + * Only the open folders are read: a collapsed folder shows nothing, so a + * change inside it cannot be visible either. The signature holds names and + * not modification times because the tree renders names, so editing a note + * leaves it untouched while adding, renaming or deleting one changes it. + */ + public async snapshot(): Promise { + if (!this.notesLocation) { + return ''; + } + + const sections: string[] = []; + + for (const directory of [this.notesLocation, ...this.expandedFolders]) { + const signature = await this.readDirectorySignature(directory); + + if (signature === undefined) { + // gone or unreadable: it displays nothing, and an open folder that + // disappeared is not worth reading on every poll from now on + this.expandedFolders.delete(directory); + continue; + } + + sections.push(`${directory}\n${signature}`); + } + + return sections.join('\n'); + } + + // list the entries of a single directory the way getNotes would show them + private async readDirectorySignature(directory: string): Promise { + try { + const items = await fs.promises.readdir(directory, { withFileTypes: true }); + + return items + .filter(item => item.isDirectory() || this.isNote(item.name)) + .map(item => (item.isDirectory() ? `d:${item.name}` : `f:${item.name}`)) + .sort() + .join('\n'); + } catch (err) { + return undefined; + } + } + + // does this file name belong in the tree, given the configured extensions? + private isNote(name: string): boolean { + // glob leaves dotfiles out, so the signature has to leave them out too + if (name.startsWith('.')) { + return false; + } + if (this.notesExtensions === '*') { + return true; + } + + const extension = path.extname(name).replace('.', '').toLowerCase(); + // an extensionless file has nothing to match against the allowed list + if (!extension) { + return false; + } + + return this.notesExtensions + .split(',') + .map(allowed => allowed.trim().toLowerCase()) + .includes(extension); + } + // refresh the tree view refresh(): void { this._onDidChangeTreeData.fire(undefined); diff --git a/src/notesWatcher.ts b/src/notesWatcher.ts new file mode 100644 index 0000000..c775636 --- /dev/null +++ b/src/notesWatcher.ts @@ -0,0 +1,170 @@ +import * as vscode from 'vscode'; +import { NotesViewProvider } from './notesViewProvider'; + +let fileWatcher: vscode.FileSystemWatcher | undefined; +let watcherListeners: vscode.Disposable[] = []; +let pollTimer: ReturnType | undefined; +let notesTree: NotesViewProvider | undefined; +let notesVisible = true; +let lastSnapshot: string | undefined; +let checking = false; +let checkGuardTimer: ReturnType | undefined; + +/* How long a check may hold the guard before later polls are allowed through. */ +const CHECK_GUARD_TIMEOUT_MS = 30_000; +const DEFAULT_INTERVAL_SECONDS = 5; +const MIN_INTERVAL_SECONDS = 2; +const MAX_INTERVAL_SECONDS = 60; + +/* + * Watches the storage location for notes added, renamed or removed outside of + * this window: another VS Code window, a sync client, the Windows side of a WSL + * setup. + * + * Registers its own teardown on the extension's lifetime. + */ +export function initNotesWatcher(context: vscode.ExtensionContext, tree: NotesViewProvider): void { + notesTree = tree; + context.subscriptions.push(new vscode.Disposable(() => stopWatching())); + restartNotesWatcher(); +} + +// call whenever the storage location or one of the watch settings changed +export function restartNotesWatcher(): void { + stopWatching(); + if (!notesTree) { + return; + } + + const configuration = vscode.workspace.getConfiguration('notes'); + if (!configuration.get('watchExternalChanges', true)) { + return; + } + + const notesLocation = String(configuration.get('notesLocation') ?? ''); + if (!notesLocation) { + return; + } + + startFileWatcher(notesLocation); + + // The watcher is only a low-latency hint: a storage location on a synced or + // Windows folder never delivers an event, inotify does not cross the mount. + // Polling is the source of truth. + const seconds = clampIntervalSeconds(configuration.get('watchIntervalSeconds')); + pollTimer = setInterval(() => void check(true), seconds * 1000); +} + +// the tree is only worth polling while someone is looking at it +export function setNotesVisible(visible: boolean): void { + notesVisible = visible; + // catch up on whatever happened while the view was hidden + if (visible) { + void check(true); + } +} + +/* + * Take what is on disk now as the reference, without redrawing anything. + * + * Called once the tree already shows the current state, either because the + * extension just rebuilt it or because the user opened a folder, which changes + * what the snapshot covers without anything having changed on disk. + * + * The reference is recomputed rather than dropped: leaving it empty would make + * the next poll unable to report anything, and a change landing in that window + * would never be seen again. + */ +export function rebaselineWatcher(): void { + void check(false); +} + +function clampIntervalSeconds(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_INTERVAL_SECONDS; + } + return Math.min(Math.max(Math.round(value), MIN_INTERVAL_SECONDS), MAX_INTERVAL_SECONDS); +} + +function startFileWatcher(notesLocation: string): void { + try { + fileWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(vscode.Uri.file(notesLocation), '**/*'), + false, // report created notes and folders + true, // ignore edits, the tree renders names only + false // report deleted notes and folders + ); + } catch (err) { + // an unmounted or unwatchable drive must not break the extension, the + // poll loop still covers it + fileWatcher = undefined; + return; + } + + const onEvent = () => void check(true); + watcherListeners = [fileWatcher.onDidCreate(onEvent), fileWatcher.onDidDelete(onEvent)]; +} + +function releaseGuard(): void { + checking = false; + if (checkGuardTimer !== undefined) { + clearTimeout(checkGuardTimer); + checkGuardTimer = undefined; + } +} + +async function check(refreshOnChange: boolean): Promise { + if (!notesTree || !notesVisible) { + return; + } + + /* + * Reading a sleeping synced folder can outlive the poll interval. + * + * This also covers the reentrant call: refreshing below fires the tree data + * event, which comes straight back here to rebaseline. The guard turns that + * into a no-op and leaves the reference on the snapshot just compared. + */ + if (checking) { + return; + } + checking = true; + // …and reading a folder that never wakes up can outlive everything. Release + // the guard on a timer so one hung call does not end the polling for good. + checkGuardTimer = setTimeout(() => { + checking = false; + checkGuardTimer = undefined; + }, CHECK_GUARD_TIMEOUT_MS); + + try { + const snapshot = await notesTree.snapshot(); + const changed = lastSnapshot !== undefined && snapshot !== lastSnapshot; + lastSnapshot = snapshot; + + if (changed && refreshOnChange) { + notesTree.refresh(); + } + } catch (err) { + // a rejection escaping the interval would only add noise to the log + console.error('Failed to check the notes location for changes:', err); + } finally { + releaseGuard(); + } +} + +function stopWatching(): void { + for (const listener of watcherListeners) { + listener.dispose(); + } + watcherListeners = []; + fileWatcher?.dispose(); + fileWatcher = undefined; + + if (pollTimer !== undefined) { + clearInterval(pollTimer); + pollTimer = undefined; + } + + lastSnapshot = undefined; + releaseGuard(); +} diff --git a/src/test/runTest.ts b/src/test/runTest.ts index 1eabfa3..e957667 100644 --- a/src/test/runTest.ts +++ b/src/test/runTest.ts @@ -1,9 +1,13 @@ import * as path from 'path'; -import { runTests } from 'vscode-test'; +import { runTests } from '@vscode/test-electron'; async function main() { try { + // VS Code sets this for processes it spawns, which would make the VS Code + // we download run as plain Node instead of Electron and reject every option + delete process.env.ELECTRON_RUN_AS_NODE; + // The folder containing the Extension Manifest package.json // Passed to `--extensionDevelopmentPath` const extensionDevelopmentPath = path.resolve(__dirname, '../../'); @@ -15,7 +19,8 @@ async function main() { // Download VS Code, unzip it and run the integration test await runTests({ extensionDevelopmentPath, extensionTestsPath }); } catch (err) { - console.error('Failed to run tests'); + // report the underlying failure, not just that something went wrong + console.error('Failed to run tests', err); process.exit(1); } } diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index d59ba91..31c6b8f 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -1,9 +1,14 @@ import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; // You can import and use all API from the 'vscode' module // as well as import your extension to test it import * as vscode from 'vscode'; // import * as myExtension from '../extension'; +import { Note } from '../../note'; +import { NotesViewProvider } from '../../notesViewProvider'; suite('Extension Test Suite', () => { vscode.window.showInformationMessage('Start all tests.'); @@ -13,3 +18,83 @@ suite('Extension Test Suite', () => { assert.equal(-1, [1, 2, 3].indexOf(0)); }); }); + +/* + * The snapshot is what tells the watcher whether anything the tree displays has + * changed, so these cover the three decisions behind it: what counts as a + * change, what deliberately does not, and how much of the tree is looked at. + */ +suite('External change detection', () => { + + let notesLocation: string; + + setup(() => { + notesLocation = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-notes-')); + fs.writeFileSync(path.join(notesLocation, 'first.md'), '# first\n'); + fs.mkdirSync(path.join(notesLocation, 'folder')); + }); + + teardown(() => { + fs.rmSync(notesLocation, { recursive: true, force: true }); + }); + + test('a note appearing on its own is picked up', async () => { + const tree = new NotesViewProvider(notesLocation, '*'); + const before = await tree.snapshot(); + + fs.writeFileSync(path.join(notesLocation, 'synced.md'), '# synced\n'); + + assert.notStrictEqual(await tree.snapshot(), before); + }); + + test('editing a note is not a change worth redrawing', async () => { + const tree = new NotesViewProvider(notesLocation, '*'); + const before = await tree.snapshot(); + + fs.writeFileSync(path.join(notesLocation, 'first.md'), '# first, rewritten\n'); + + // the signature holds names rather than modification times, which is what + // keeps a poll cheap and an edit from refreshing the tree for nothing + assert.strictEqual(await tree.snapshot(), before); + }); + + test('only the open folders are watched', async () => { + const tree = new NotesViewProvider(notesLocation, '*'); + const nested = path.join(notesLocation, 'folder', 'nested.md'); + + const closed = await tree.snapshot(); + fs.writeFileSync(nested, '# nested\n'); + assert.strictEqual(await tree.snapshot(), closed, 'a closed folder shows nothing, so it costs nothing'); + + tree.setExpanded(new Note('folder', notesLocation, '', '', true), true); + const open = await tree.snapshot(); + fs.unlinkSync(nested); + assert.notStrictEqual(await tree.snapshot(), open, 'an open folder is on screen, so it is watched'); + }); + + test('an open folder that disappears is reported once, then settles', async () => { + const tree = new NotesViewProvider(notesLocation, '*'); + tree.setExpanded(new Note('folder', notesLocation, '', '', true), true); + + const before = await tree.snapshot(); + fs.rmSync(path.join(notesLocation, 'folder'), { recursive: true, force: true }); + + const reported = await tree.snapshot(); + assert.notStrictEqual(reported, before, 'the folder leaving the tree is a change'); + // a watcher compares one snapshot to the next, so a folder that is gone has + // to stop moving, or every poll would redraw the tree for nothing + assert.strictEqual(await tree.snapshot(), reported, 'and it stops being one'); + }); + + test('a file the tree would not show is not a change', async () => { + const tree = new NotesViewProvider(notesLocation, 'md'); + const before = await tree.snapshot(); + + // the snapshot filters extensions itself, so it has to agree with getNotes + fs.writeFileSync(path.join(notesLocation, 'ignored.txt'), 'not a note\n'); + assert.strictEqual(await tree.snapshot(), before); + + fs.writeFileSync(path.join(notesLocation, 'counted.md'), '# counted\n'); + assert.notStrictEqual(await tree.snapshot(), before); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 07cb97b..0e831b4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ ], "sourceMap": true, "rootDir": "src", + "skipLibCheck": true, /* don't type-check dependency .d.ts files, glob's lru-cache conflicts with the es6 lib */ "strict": true, /* enable all strict type-checking options */ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules */ "typeRoots": [