From 4c0c188dacbf90daca18a718265e014cedd830f2 Mon Sep 17 00:00:00 2001 From: Sillot Date: Tue, 11 Aug 2026 13:29:18 +0200 Subject: [PATCH 1/2] feat(notes): keep the view in sync without a window reload Changing the storage location used to ask for a window reload before the Notes view would show anything from the new folder. The tree now applies the change in place, and keeps itself in sync with what is on disk. - the storage location and the allowed extensions apply immediately - notes and folders added or removed outside of the window are picked up: file system events where they arrive, and a poll of the open folders, which is what covers synced and Windows folders where none ever do - the view refreshes once a create or delete has actually completed on disk, rather than before the write - reveal a note or folder in the file explorer of the operating system, going through explorer.exe on WSL where the built-in command hands a Linux path to Windows - tree items carry a stable id, so an automatic refresh no longer collapses the folders the user had opened Also unblocks the test setup, which could not run: an eslint rule dropped upstream, dependency typings failing the build, the renamed vscode-test package, and the ELECTRON_RUN_AS_NODE that VS Code exports into the terminals it spawns. --- .eslintrc.json | 10 +- CHANGELOG.md | 14 + package-lock.json | 573 ++++++++++++++++++------------- package.json | 46 ++- src/extension.ts | 136 ++++++-- src/note.ts | 4 + src/notesViewProvider.ts | 97 ++++++ src/notesWatcher.ts | 170 +++++++++ src/test/runTest.ts | 9 +- src/test/suite/extension.test.ts | 85 +++++ tsconfig.json | 1 + 11 files changed, 876 insertions(+), 269 deletions(-) create mode 100644 src/notesWatcher.ts 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..cbe0ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ 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 + +### 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..b0d099f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,23 @@ "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.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 +125,11 @@ "dark": "resources/dark/rename.svg" } }, + { + "command": "Notes.revealInOS", + "title": "Reveal in File Explorer", + "category": "Notes" + }, { "command": "Notes.setupNotes", "title": "Settings", @@ -148,6 +170,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 +198,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 +232,11 @@ "command": "Notes.refreshNotes", "when": "view == notes && !viewItem", "group": "navigation@3" + }, + { + "command": "Notes.revealInOS", + "when": "view == notes && !viewItem", + "group": "3_open@1" } ] }, @@ -242,16 +284,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..83c109d 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); @@ -147,9 +173,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 +270,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 +281,6 @@ export class Notes { }); } }); - // refresh tree after creating new note - tree.refresh(); } else { // report @@ -292,10 +320,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 +357,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 +527,13 @@ 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); // 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, true).then(() => { + // apply the new location right away, no window reload needed + tree?.updateConfiguration(selectedLocation, String(Notes.getNotesExtensions())); + vscode.window.showInformationMessage(`Notes are now stored in '${selectedLocation}'.`); }); } }); 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": [ From 8e322f56cda23db9f7492f7389addf095bfa17ec Mon Sep 17 00:00:00 2001 From: Sillot Date: Tue, 11 Aug 2026 14:20:49 +0200 Subject: [PATCH 2/2] feat(notes): choose where the storage location is saved Picking a storage location always wrote it to the user settings. It can now go to the workspace settings instead, which is the default, so a folder of notes can travel with the project. notes.storeLocationInWorkspace turns that off for anyone who would rather keep a personal path out of a .vscode/settings.json that ends up committed. It is scoped to the application, so a repository cannot set it back. --- CHANGELOG.md | 1 + package.json | 6 ++++++ src/extension.ts | 27 +++++++++++++++++++++++++-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbe0ff2..cca6d1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * 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 diff --git a/package.json b/package.json index b0d099f..ecc2061 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,12 @@ "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, diff --git a/src/extension.ts b/src/extension.ts index 83c109d..5cfe400 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -157,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 @@ -529,11 +547,16 @@ export class Notes { 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', selectedLocation, true).then(() => { + notesConfiguration.update('notesLocation', selectedLocation, target).then(() => { // apply the new location right away, no window reload needed tree?.updateConfiguration(selectedLocation, String(Notes.getNotesExtensions())); - vscode.window.showInformationMessage(`Notes are now stored in '${selectedLocation}'.`); + + let savedIn = target === vscode.ConfigurationTarget.Workspace ? 'workspace' : 'user'; + vscode.window.showInformationMessage(`Notes are now stored in '${selectedLocation}', saved in the ${savedIn} settings.`); }); } });