diff --git a/.claude/rules/code-styles.md b/.claude/rules/code-styles.md index a5a6e64..3c8af47 100644 --- a/.claude/rules/code-styles.md +++ b/.claude/rules/code-styles.md @@ -13,7 +13,7 @@ Conventions for NoteRoot code. They describe how the code is written today. When ## Formatting -Prettier decides (`.prettierrc`): 2 spaces, double quotes, semicolons, line width 100, trailing commas where ES5 allows them, CRLF line endings (`.editorconfig`). Prettier is a dev dependency: run `npm run format` after editing and before finishing (`npm run format:check` only checks). `media/` (the webview page) is not formatted by it. Always use braces for `if`, `for` and `while` (`curly` is a lint rule). Use `===` (`eqeqeq`). +Prettier decides (`.prettierrc`): 2 spaces, double quotes, semicolons, line width 100, trailing commas where ES5 allows them, CRLF line endings (`.editorconfig`). Prettier is a dev dependency: run `npm run format` after editing and before finishing (`npm run format:check` only checks). `webview/` (the pages of the webviews: .js and .css) is not formatted by it. Always use braces for `if`, `for` and `while` (`curly` is a lint rule). Use `===` (`eqeqeq`). ## Naming diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8056ea8..168686f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,13 +52,13 @@ jobs: - name: Package run: npx vsce package --no-dependencies --out noteroot.vsix - # The package holds only the bundle, media/, README, CHANGELOG, LICENSE and package.json, and stays small + # The package holds only the bundle, media/, webview/, README, CHANGELOG, LICENSE and package.json, and stays small # (the screenshots in docs/ must never end up in it). - name: Check the package contents if: runner.os == 'Linux' shell: bash run: | - unexpected=$(unzip -Z1 noteroot.vsix | grep -v '/$' | grep -v -E '^(\[Content_Types\]\.xml|extension\.vsixmanifest|extension/(package\.json|readme\.md|changelog\.md|LICENSE\.txt|dist/extension\.js|media/.+))$' || true) + unexpected=$(unzip -Z1 noteroot.vsix | grep -v '/$' | grep -v -E '^(\[Content_Types\]\.xml|extension\.vsixmanifest|extension/(package\.json|readme\.md|changelog\.md|LICENSE\.txt|dist/extension\.js|media/.+|webview/.+))$' || true) if [ -n "$unexpected" ]; then echo "::error::Unexpected files in the package:" echo "$unexpected" diff --git a/.vscodeignore b/.vscodeignore index ad85e87..315d1c1 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,8 +1,9 @@ -# Ship only the bundle, media/, README, CHANGELOG, LICENSE and package.json. +# Ship only the bundle, media/, webview/, README, CHANGELOG, LICENSE and package.json. # Source maps stay out: the production build has none, and an old dev map must not slip in. ** !dist/extension.js !media/** +!webview/** !README.md !CHANGELOG.md !LICENSE diff --git a/CLAUDE.md b/CLAUDE.md index d7d549e..eb4f83c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,8 @@ src/tree/ NotesTreeProvider, NoteItem, NotesDragAndDrop, DoubleClickTra src/webview/ QuickNoteViewProvider, messages.ts (typed message contract + validation) src/commands/ index, general/root/tree/file/quickNote/search/recent/pinned/label/labelView/settings/tagPattern/tagging/header/encryption/sync/syncKeyPrompt commands, patternLists, labelDialogs, dialogs, openFile, tabs, safe (error boundary), configNotifications, walkthrough -media/ quickNote.css/js, noteroot.svg (Activity Bar), icon.png, walkthrough/*.md +media/ noteroot.svg (Activity Bar), icon.png, walkthrough/*.md +webview/ quickNote/, settingsPage/, labelsPage/: the page of each webview (.js and .css, shipped as they are, not bundled), loaded by the providers in src/webview ``` ## Behaviour digest @@ -90,7 +91,9 @@ media/ quickNote.css/js, noteroot.svg (Activity Bar), icon.png, walk - **Pinned** (the group of the view above): `PinnedNotesService` keeps the pinned files in `custom.pinned` of `.noteroot/config.json` (array of `/`-separated paths relative to the root, in pin order, so they belong to the notes folder). Reading is `configService.ensure` each time (never cached); writing is `ConfigService.updateCustom`, so a damaged or newer config is refused with `ConfigLockedError` and entries that are not texts survive. Commands `noteRoot.pinNote` (tree files and Recent items) and `noteRoot.unpinNote` (pinned items) take the clicked item plus the selection and pin only `file` items. `onDidRenameFiles` rewrites the paths with `renameRecent` (only when a pin is affected) and drops pins that left the root. `PinnedRecentProvider` shows pinned items with context value `noteRoot.pinnedItem` and hides, but does not delete, entries whose file is gone or hidden. - **Tags & Categories** (`noteRoot.labels`, tree view between Notes and Pinned & Recent, always shown; created with `createTreeView(..., { canSelectMany: true })` in `extension.ts`: a view registered with `registerTreeDataProvider` has no multi selection, which is why Ctrl+click did nothing; the context-menu commands of `labelViewCommands.ts` get `(clicked, selected)` and use `entriesOf` to take the `LabelEntryItem`s: `deleteLabel` asks once for several (`deleteManyConfirm`), `removeLabelFile` and `removeLabelKeyword` work on all, `changeLabelFile` asks one file for all the selected categories; `renameLabel` and `setLabelKeyword` use the clicked one): `LabelsTreeProvider` has two groups, **Categories** (open at first; `LabelEntryItem` with the file as description, "Quick Note file" without one; every category is collapsible, because VS Code draws a row without a twistie further left than its siblings that have one, and its only child is a `CategoryFileItem`: the file of the category, or the Quick Note file when it has none; a click runs `openItem`, and the file shows "not created yet" and does nothing until it exists) and **Tags** (collapsed at first, so nothing is scanned until it is opened; each tag shows `N notes`, children are `TagNoteItem`s that run `noteRoot.openAtLine` with `uri, line, column, length`, which selects the tag). The lists come from `LabelService.getLabels()`; the notes of the tags from `TagIndexService.scan(names)`, one walk of the notes folder like the search (excludes, no symbolic links, text files up to 1 MB via `SearchService.readText`) that looks only for tags that were created. `findTagsInText` is the pure matcher: a tag is `#` at a line start or after white space or `( [ {`, followed by non-space non-`#` text, trailing `.,;:!?)]}'"` removed, compared lower case and with spaces as `-` (`tagKey`, from `formatTag`); the first use gives the position. The scan result is kept in the provider until `refresh()` (label changes, root or state changes, the refresh button) or until files change (`onDidChangeFiles`, then a 1.5 s delay: the file of a category appears with its first note). Actions: `createCategory`, `createTag` (also in the palette), `renameLabel`, `deleteLabel`, `changeLabelFile`, `setLabelKeyword` and, without a question, `removeLabelFile` (`setCategoryFile(name, undefined)`: the category uses the Quick Note file again, the file on disk is untouched) and `removeLabelKeyword` (`setKeyword(kind, name, undefined)`). The menu entries Remove File and Remove Keyword depend on the context value of the row, which `LabelEntryItem` builds as `noteRoot.label.` + `.hasFile` + `.hasKeyword` (in this order, each only when the entry has it) and the `when` clauses test with `=~`; the others use `performLabelAction` in `labelCommands.ts`, which the Manage list uses too, so questions and messages are the same and a damaged or newer config is refused the same way. - **Auto create category for every file** (`AutoCategoryService`, on/off in `globalState` `noteRoot.autoCategories` and the context key of the same name, two commands `noteRoot.enableAutoCategories` / `disableAutoCategories` that swap in the title bar of the Tags & Categories view, like the Advanced mode): while on, every note file (`isNoteFile`: `md markdown mdx txt text rst adoc asciidoc org tex log`) found by `SearchService.walkFiles` (excludes, no symbolic links) becomes a **virtual** category `{ name, file, label? }` made by the pure `categoriesForFiles` (`name` is the identity, what is stored and reported with `choiceChanged`: the path without extension, the whole path for both files when two would collide, case-insensitive; `label` is what the lists show: the file name without the extension, left out when two labels would be equal, then the row shows the `name`; names over 150 characters or with control characters are left out; at most `MAX_AUTO_CATEGORIES` = 500, sorted naturally, `truncated` tells when files were left out). They are **never stored in the config file**. `list()` is cached and made again 1 s after `onDidChangeFiles` (or at once on a root change); `onDidChange` fires only when the list really differs. `LabelService.getChoosableCategories()` = stored categories + the virtual ones (a stored name wins, case-insensitive; a label equal to a stored name or an earlier label is dropped) and is used by the Quick Note box (`readLabels`, which also sends `categoryLabels` name → label and `categoryTips` name → file path in `init` and `options`; the page shows the label, puts the path in the `title` of the row and of the box, and matches typed text against the label and the whole name) and by `getActiveCategoryFile`, so a chosen virtual category writes to its file exactly like a real one (still only while the Advanced mode is on, still `CategoryFileError` outside the root). `getLabels()` stays stored-only, so the Manage list, create/rename validation and the panel actions never touch virtual categories. `LabelService` re-emits `onDidChange` when the virtual list changes, which refreshes the box and the panel. The panel shows them in a collapsed group **Every file** (`AutoGroupItem`, children `AutoCategoryItem`, a click opens the file). -- **Settings** (`noteRoot.settings`, tree view after Pinned & Recent, last in the sidebar, collapsed at first): `SettingsCatalog.ts` is the one list, `SETTING_DEFS`, of everything the view shows: an `id` (the key of its texts in `Strings.settingsView.items`), a `group` (`notes`, `quickNote`, `tags`), a `kind` (`choice`, `text`, `number`, `list`, `action`, `switch`), the `noteRoot.*` `key` (none for the three switches, which live in `globalState` services: `AdvancedModeService`, `AutoCategoryService`, `AutoTagPatternService`), `choices`, `validate`/`parse` for typed values, `resettable`, `read(context)` (the value now, through the typed getters of `SettingsService`, so an unusable value shows its default) and `defaultValue`. **A new setting in `package.json` needs a row here**; keep the two lists in step (a check that every `package.json` setting has a row is worth running after adding one). `SettingsTreeProvider` shows groups (open) and `SettingItem` rows (name, value text from `describeValue`, a tooltip with the description and the default, icon by kind or a filled circle for a switch that is on, context value `noteRoot.setting` + `.key` when it is a VS Code setting + `.changed` when it was changed from the default, click = `noteRoot.editSetting` with the id). It refreshes on `SettingsService.onDidChange` (any `noteRoot.*` change, wherever it was made) and on the three switches. `settingsCommands.ts` edits by kind: `choice` a QuickPick, `text` and `number` an InputBox checked with the def's `validate` (a date format keeps its spaces, the others are trimmed), `list` (the hidden files) a QuickPick loop with change/delete buttons, **Add entry…** and **Back to the default list**, `action` runs its own command (`changeRootFolder`, `changeQuickNoteFile`), `switch` toggles with `writeSwitch`. `SettingsService.setValue` writes where the user's value lives (Workspace if a value is set there, else Global) **except for the folder settings** (below), `resetValue` removes the value everywhere it is set (for a folder setting only the folder's value while it has one), `hasExplicitValue` tells whether one is set (folder or VS Code), `sourceOf(key)` gives `folder`, `user` or `default`. Menus: `resetSetting` only on rows with `.changed` (never the notes folder, which is not `resettable`), `showSettingInEditor` (`@id:noteRoot.`) only on `.key` rows, `editSetting` on every row; the title bar button `openAllSettings` opens the Settings editor with `noteRoot`. +- **Settings** (`noteRoot.settings`, tree view after Pinned & Recent, last in the sidebar, collapsed at first): `SettingsCatalog.ts` is the one list, `SETTING_DEFS`, of everything the view shows: an `id` (the key of its texts in `Strings.settingsView.items`), a `group` (`notes`, `quickNote`, `tags`), a `kind` (`choice`, `text`, `number`, `list`, `action`, `switch`), the `noteRoot.*` `key` (none for the three switches, which live in `globalState` services: `AdvancedModeService`, `AutoCategoryService`, `AutoTagPatternService`), `choices`, `validate`/`parse` for typed values, `resettable`, `read(context)` (the value now, through the typed getters of `SettingsService`, so an unusable value shows its default) and `defaultValue`. **A new setting in `package.json` needs a row here**; keep the two lists in step (a check that every `package.json` setting has a row is worth running after adding one). `SettingsTreeProvider` shows groups (open) and `SettingItem` rows (name, value text from `describeValue`, a tooltip with the description and the default, icon by kind or a filled circle for a switch that is on, context value `noteRoot.setting` + `.key` when it is a VS Code setting + `.changed` when it was changed from the default, click = `noteRoot.editSetting` with the id). It refreshes on `SettingsService.onDidChange` (any `noteRoot.*` change, wherever it was made) and on the three switches. `settingsCommands.ts` edits by kind: `choice` a QuickPick, `text` and `number` an InputBox checked with the def's `validate` (a date format keeps its spaces, the others are trimmed), `list` (the hidden files) a QuickPick loop with change/delete buttons, **Add entry…** and **Back to the default list**, `action` runs its own command (`changeRootFolder`, `changeQuickNoteFile`), `switch` toggles with `writeSwitch`. `SettingsService.setValue` writes where the user's value lives (Workspace if a value is set there, else Global) **except for the folder settings** (below), `resetValue` removes the value everywhere it is set (for a folder setting only the folder's value while it has one), `hasExplicitValue` tells whether one is set (folder or VS Code), `sourceOf(key)` gives `folder`, `user` or `default`. Menus: `resetSetting` only on rows with `.changed` (never the notes folder, which is not `resettable`), `showSettingInEditor` (`@id:noteRoot.`) only on `.key` rows, `editSetting` on every row; the title bar button `openAllSettings` opens the Settings editor with `noteRoot`, and the button before it (`openSettingsPage`, `$(edit)`) opens the **Settings page** (below). +- **Settings page** (`noteRoot.openSettingsPage`, title bar of the Settings view and palette): `SettingsPageProvider` (`src/webview/`, one `WebviewPanel` in the editor, shown again when open) with `webview/settingsPage/settingsPage.js` and `.css` and the contract `settingsMessages.ts`. It shows the same `SETTING_DEFS` as the view as rows (`SettingRow`: label, description, value, default, changed, origin, choices, unit) grouped, with a search box and an Only changed filter; `choice` is a select, `toggle`/`switch` a checkbox, `text`/`number` a text box (sent on `change`), `list` a textarea with one entry per line and an Apply button, `action` a Change… button that runs its command. The page only sends `edit`/`reset`/`action` messages (validated in `parseSettingsPageMessage`); the extension saves with `applySettingEdit` (`SettingsCatalog.ts`: the same validation and writing as the QuickPick/InputBox of `settingsCommands.ts`, which share `typedValue`) and answers `result` with the error message, or `update` after any settings change (a box with the focus is not overwritten). Writing errors (damaged config, no notes folder) go through `reportError`. +- **Tags & Categories page** (`noteRoot.openLabelsPage`, button `$(edit)` in the title bar of the Tags & Categories view and palette): `LabelsPageProvider` (`src/webview/`, one `WebviewPanel`, `webview/labelsPage/labelsPage.js` and `.css`, contract `labelsMessages.ts`) shows what the view has: Categories (file or Quick Note file with a link, keyword, buttons Change file / Remove file / Keyword / Remove keyword / Rename / Delete, **New category**), **Every file** (the switch `autoCategories` and the virtual categories), Tags (notes with links to the line, same buttons, **New tag**) and Tag Patterns (the switch `autoTagPatterns`, Add / Change / Delete, the tags each pattern made and their notes). It reads the lists from `LabelService` and the notes from the scan of the view (`LabelsTreeProvider.tagNotes()` / `patternMatches()`, kept until `refresh()`), rebuilds on `labelsProvider.onDidChangeTreeData` and the Advanced switch, and sends the lists first and the scanned notes in a second `update`. The page writes nothing: every button is a message that the extension runs with the code of the view's menus (`performLabelAction`, `setCategoryFile`/`setKeyword` for the removals, and the commands `addTagPattern`, `editTagPattern` and `deleteTagPattern`, which also accept the pattern as text, and the four enable/disable commands of the switches), so questions, rules and status messages are the same. Notes and files are named by id or by a path from the model, never by a path from the page. - **Folder settings** (second layer of settings): `FOLDER_SETTING_KEYS` in `constants/settings.ts` (`defaultExtension`, `excludePatterns`, `sortOrder`, `quickNoteFile`, `quickNoteTimestampFormat`, `quickNotePosition`, `quickNoteHeadingLevel`, `quickNoteTagLine`, `tagOnSave`) can live in `custom.folderSettings` of `.noteroot/config.json`, so they travel with the notes. `FolderSettingsService` (implements `FolderSettingsStore`, attached to `SettingsService` with `attachFolderStore`) reloads on root or state change and through a watcher on the config file (200 ms debounce), and hands the record to `SettingsService.setFolderValues`, which keeps only values that pass `isValidFolderValue` (the same rules as the getters; an unusable one is ignored so the VS Code setting applies) and fires `onDidChange` with a plain event that answers `affectsConfiguration` for the changed keys (so `SettingsService.affects` works for them). `SettingsService.read` returns the folder value first, then the VS Code setting, so every getter and the default of package.json keep working. `setValue` of a folder key writes through `FolderSettingsService.write` = `ConfigService.updateCustom` (keeps unknown fields, removes `custom.folderSettings` when it is empty, throws `ConfigLockedError` for a damaged or newer file, which the command boundary reports) as long as `canWrite()` (a notes folder exists); without one it goes to VS Code as before. **A damaged or newer config means the folder values are ignored.** `resetValue` peels one layer: the folder's value first, then (second reset) the user's. The Settings view shows `· this folder` in the description and the origin in the tooltip (`SettingItem` `source`). `noteRoot.excludeItem` ("Hide from NoteRoot", tree context menu of files and folders, `fileCommands.ts`) adds the anchored path `/` of each clicked or selected item to the effective `excludePatterns` through `settings.setValue` (so into the folder list; entries already there, any case, are skipped) and offers Undo, which writes the old list back; `excludePatterns` of the folder **replaces** the VS Code list, and `ExcludeMatcher.update` always adds `.noteroot` (a list kept in the folder could otherwise show the config folder); the first state check of `RootFolderService` runs before the folder values are read, and the change of `excludePatterns` makes it run again. Other settings (`rootPath`, which is needed to find the config, and `largeFileThresholdMB`, which depends on the machine) stay VS Code only; the switches (Advanced, Category for every file, Tag from pattern) are still per machine in `globalState`. **Folder-only settings** (`FOLDER_ONLY_KEYS`: `noteFileTypes`, `maxRecentNotes`, `maxTextFileSizeMB`, `maxSearchResults`; defaults in `SettingDefaults`, limits in `NumberLimits`, extension rule `NOTE_FILE_TYPE_PATTERN`) are in the folder list too but **not declared in package.json**: `SettingsService.read` never asks VS Code for them, `setValue` throws `RootMissingError` (`settingsView.needsFolder`) when there is no notes folder, `hasVsCodeValue` is false, and the catalog test checks that none of them is in package.json while every other `package.json` setting has a row. Their rows have no `.key` in the context value (Show in Settings Editor does not apply). Getters: `noteFileTypes` (lower case), `maxRecentNotes`, `maxTextFileBytes` (MB times 1 000 000), `maxSearchResults`; `SearchService`, `RecentNotesService` and `AutoCategoryService` get `SettingsService` as their **last** constructor argument (`SearchService.resultLimit` for the command); `isNoteFile(name, extensions?)` takes the set; `NoteTaggingService` uses `maxTextFileBytes` for Tag when saving. A list row can validate and normalize its typed entry (`validateEntry`, `normalizeEntry`: `.md` becomes `md`). **Group More** (`MORE_SETTINGS` in `SettingsCatalog.ts`, built with `numberDef`/`flagDef`; numbers have a `unit` that decides how the value is shown): `sortReversed` (`compareItems(a, b, reversed, modified?)`, folders stay first), `confirmDelete` (the question is skipped only when the Quick Note file is not involved), `caseSensitiveSearch` (`findFirstMatch(text, needle, caseSensitive)` and the name match; the needle is lower-cased only when case does not matter), `minSearchLength` / `resultLimit` (getters of `SearchService`), `undoSteps` (`FileOperationsService.recordUndo`), `doubleClickMs` (`DoubleClickTracker` takes a function and reads it at each click), `statusMessageSeconds` (`src/util/status.ts`: `showStatus(message, scale)`, wired once with `configureStatusMessages` in `extension.ts`; every command shows its status bar messages with it), `maxPatternTags` (written as a `data-*` attribute of the Quick Note page, so it applies when the panel is opened again), `minKeywordLength` / `maxLabelNameLength` / `maxPatternTags` (`LabelService` gets `SettingsService` as an **optional last** argument and falls back to the defaults), `maxAutoCategories`, and `startAdvancedOn` / `startAutoCategoriesOn` / `startAutoTagPatternsOn`: the three switch services keep `stored` (what the user chose on this machine, from `globalState`, or undefined) and `isEnabled` is `stored ?? the start setting`; they take `SettingsService` as the last constructor argument, and a change of the start setting while nothing is stored republishes the context key and fires `onDidChange`. Not made a setting: the separator that replaces white space in tag names (`-`), because it is part of the written notes and many pure functions use `formatTag`. - **Search** (`noteRoot.search`, QuickPick, magnifier in the tree title bar): `SearchService.search` is an async generator that walks the root breadth-first with `workspace.fs`, honours `ExcludeMatcher`, never enters symlinks, reads text files up to 1 MB (skips binary files), returns the first matching line per file and stops at 200 results. Case-insensitive substring match, minimum 2 characters, no index. `findFirstMatch` is the pure matching function. A result opens at its line and is revealed in the tree. - **Sync** (view `noteRoot.sync`, named **Actions**, after Pinned & Recent, collapsed at first; all three planned steps are done: connect / push / pull, the history option, the encryption): syncs the notes folder with a remote **service**. The sync does not know which one: everything goes through the interface `SyncProvider` (`SyncProvider.ts`, types only). A provider has `id`, `label` (`GitHub`), `targetNoun` (`repository`), `versionNoun` (`commit`), texts for the picker, `hash(bytes)` (the hash the remote reports for the content, so local files and remote files compare directly), `signIn()` (may open the VS Code sign-in, called by commands only), `listTargets()`, optional `creator` (make a new private target) and `finder` (find one by a typed name), `currentVersion(target)` (one small request), `snapshot(target)` (version + files + `truncated`, undefined when the remote is empty), `download(target, file)` and `upload(target, expectedVersion, changes, message)` (changes carry a lazy `read()` or none for a delete; it must refuse when the remote moved on, never overwrite). A target is `{ id, label, detail?, isPrivate }`; the `id` is text only the provider understands. **To add a service:** write a class that implements `SyncProvider`, put its texts under `Strings.sync.`, and add it to the `syncProviders` array in `extension.ts`; nothing in `SyncService`, `SyncPlan`, the view or `syncCommands.ts` changes (the commands ask which service when there is more than one). The first provider is `GitHubSyncProvider`, an adapter over `GitHubClient` and `GitHubAuth`: the **REST API on the global `fetch`, no git and no dependency**; the version is the commit, the target id is `owner/name@branch` (default branch), the hash is the Git blob hash (`GitHash.ts`, equals `git hash-object`), a push is blobs + one tree on top of the head tree + one commit + `updateBranch` (not forced, except in the one-commit mode below), and an empty repository is started with the marker file `.noteroot-sync` through the Contents API because the Git Data API fails on it. `GitHubAuth` wraps `vscode.authentication.getSession("github", ["repo"])` (`token()` is silent and is what the client uses, `signIn()` may open the sign-in), so NoteRoot never sees a password and never stores the token. Every GitHub failure is a `GitHubError` with a friendly message and the HTTP `status`; nothing from a note is ever logged. **`SyncPlan.ts` is pure** (`diffStates`, `planPull`, `compareStates`, `stateAfterPull/Push`, `isSafeRemotePath`, `isSyncedRemotePath`, `copyName`): three states take part, the **base** (path to hash of the last sync, kept per notes folder in `globalState` `noteRoot.sync` with the provider id, the target and the last seen version), the local files (hashed with `provider.hash`, cached by mtime and size, but only for files that were still for 2 s, so a quick edit of the same size is never missed) and the remote files. A path that is identical on both sides is no change for anyone. `SyncService(memento, rootService, pathGuard, searchService, excludeMatcher, providers, logger)` (no UI; questions arrive as the `ConflictResolver` callback) has one lock (`exclusive`, a second action gets `SyncRefusedError`), `refresh`, `push` (refused with "pull first" when the remote version is not the last seen one, like git; the **commands** turn that refusal into a question, **Pull, then Push**, and `connect` pulls what the target already has; the hashes the provider answers become the new base), `pull` (conflicts asked **before anything is written**, undefined cancels; everything is downloaded first; replaced and deleted files go to the Trash; an open editor with unsaved changes counts as changed; a path that could not be applied keeps its old base and the version stays, so it is tried again), `checkForNewVersion` (the version only), the **status at the start** (`initialize`, a root change and `onDidChangeState` call `showLocalChanges`: it scans the files and compares them only with the base (`localOnly`), no request, and the snapshot has `remoteCompared: false`, which the view shows as *Remote not checked yet*; with the automatic check on, `autoRefreshNow` does the full comparison `STARTUP_REFRESH_DELAY_MS` = 10 s after the start, quietly, with one notice for a new version), the **automatic refresh of the local changes** (`rootService.onDidChangeFiles` starts a 2 s quiet timer, then `recompareLocal` scans the files again and compares them with `lastRemote`, the remote as it was read last; no request, skipped while an action runs, and only after a first comparison has set `snapshot.status`), `setKeepLast(count)`, `setIncludeHidden(value)`, `readRemoteFile(path)` and `connect(provider, target)` / `disconnect`. **Diff:** a click on a file row runs `noteRoot.syncShowChange` with `(side, path, kind)` (the tree item's command; the arguments are validated). `readRemoteFile` downloads the file with the hash of `lastRemote` (through the provider, so an encrypted repository gives the plain content; refused while an action runs, nothing is changed). `syncCommands.ts` turns it into a read-only in-memory document (`OnlineViewService`, scheme `noteroot-online`, text dropped when its tab closes, like `DecryptedViewService`) and runs `vscode.diff`: local side `modified` = online on the left, your file on the right; remote side `modified` = your file on the left, online on the right; a file that only one side has opens alone (a new local file with `noteRoot.openNoteFile`); binary (a NUL in the first 8000 bytes) or over `MAX_DIFF_BYTES` = 2 MB gives a message. **Hidden files** (per connection, `includeHidden` in the stored connection, **off by default**, asked by `connect` after the encryption question through `askHiddenMode` = a QuickPick, then a modal confirmation to include; changed later with `noteRoot.syncHidden`; shown as `· hidden files included`): off, `scanLocal` uses the normal walk (exclude patterns) and `usableRemote` skips remote paths the matcher hides; on, `SearchService.walkFiles(undefined, { isSkipped })` replaces the exclude patterns by `isNeverSynced` and the remote paths are not filtered. `setIncludeHidden(false)` removes the hidden paths from the base (except `config.json`) so the next comparison does not propose to delete them online, and drops the remote cache; both directions reset the snapshot, so a Refresh follows. **Limited history** (per connection, `keepLast` in the stored connection: a number from 1 to `MAX_KEEP_LAST` = 100, **undefined = keep every commit, the default**; asked by `connect` right after the target is chosen, cancelling it connects nothing, and changed later with `noteRoot.syncHistory`, both through `askHistoryMode` = a QuickPick of *every commit* / *the last commits…* (an input box, starts with `DEFAULT_KEEP_LAST` = 5, 1 to 100) / *one commit* (= 1), then a modal confirmation when the change can drop commits; shown as `· last N commits` or `· one commit` in the view; only for a provider with `canLimitHistory`): `provider.upload(..., { keepLast })`. `GitHubSyncProvider.limitHistory` reads the newest `keepLast - 1` commits (`getCommit`, first parents), and when the history is longer makes them again, oldest first, with the same tree, message, author and date (the oldest has no parent), then the new commit on top, and moves the branch by force (`replaceHistory`); a history that is already short enough just gets the new commit, without force. Other computers are not affected because the sync compares files, not ancestry. This is the only forced update NoteRoot makes, and it is always the user's choice. Conflict kinds `both-changed`, `remote-deleted`, `local-deleted`; resolutions `mine`, `theirs`, `both` (the online file is saved as `name (online).ext`, never for the config). **Paths from the remote are untrusted**, whatever the provider: `isSafeRemotePath` refuses empty, `.`, `..`, backslash, control characters, Windows reserved names and trailing dot or space; only `.noteroot/config.json` is taken from `.noteroot`; hidden-by-exclude paths (unless the user chose to include hidden files), names that differ only in case, the marker and the **never-synced names** (`isNeverSynced`: `.git`, `.noteroot`, `.DS_Store`, `Thumbs.db`, `desktop.ini`, in any segment and in every mode, also refused by `isSyncedRemotePath`) are skipped (counted, logged by path); `resolveRelative` checks every write again; the config is replaced only by JSON that is an object. Files over 25 MB and more than 10 000 files are refused. The optional **check for new changes** is a per-machine switch in `globalState` (`noteRoot.syncAutoCheck` and the context key of the same name, two swapping commands in the `...` menu, **off by default**): every 5 minutes and when the window gets focus, only while focused and idle, quiet on failure, one notice per new version (`onDidFindNewVersion`). `SyncTreeProvider` shows the group ` Sync` (target and lock icon), an info line, **Local changes** and **Remote changes** with files (icons for new, changed, deleted, a warning for a conflict) and puts a badge on the view; the buttons (`navigation@0..2`: check, pull, push) and the `...` entries are in `view/title`, the welcome text depends on `noteRoot.hasRoot` and `noteRoot.syncConnected`. `syncCommands.ts` has `runPull` / `runPush` shared by connect, Push and Pull (Push with news: "Pull, then Push" runs the pull, then the push again with `afterPull`, which never loops), and does the questions (service picker, target picker with create / type / list, public-target warning, edited message as the confirmation of a push, unsaved-notes warning, conflict review). The command ids keep the first names (`syncChangeRepository` changes the target of any service). **Encrypted sync** (`SyncCrypto.ts` pure, `EncryptedSyncProvider.ts`, `SecretKeyStore.ts`, `syncKeyPrompt.ts`): a **decorator** around any `SyncProvider`, so `SyncService`, `SyncPlan` and the view do not know about it; the stored connection has `encryption: { names: "plain" | "hidden", salt }` and `SyncService.providerOf` wraps the provider with `EncryptionSupport.wrap` (one wrapper for each connection, it keeps the key and the last index). `EncryptionSupport.inspect(provider, target)` says `empty` / `plain` / `encrypted` (reads the header); `create` makes the salt and key of a new encrypted target and keeps it. **Format:** `.noteroot-encrypted` (plain JSON header: format, names, salt, a check text sealed with the key), `.noteroot-index` (sealed JSON: path to `{ name, hash }`, the hash is the sha256 of the **plain** note, because every sealing differs, so the sealed files online cannot be compared), and one sealed file for each note (`name.ext.enc`, or `data/.enc` with hidden names). Sealing is AES-256-GCM, `NRS1` + iv + tag + data, the **path of the note is the authenticated data** (a file moved to another note fails), the key is `scrypt(master key NFC, salt, N=2^15, r=8, p=1)` made **once** (per-file scrypt would take minutes). The decorator reports plain paths and plain hashes (`hash()` is sha256), downloads and opens (and checks the sha256 against the index), and on upload seals the changed notes, rewrites the index and writes the header on the first push. `snapshot` refuses a repository that has files but no header (plain and encrypted are never mixed) and a header with another salt. **The master key is never stored**: `SecretKeyStore` keeps the derived key hex in `SecretStorage` under `noteRoot.sync.key:::`; a saved key that no longer matches the header is deleted and the master key is asked again (`KeyAsker`, `createKeyAsker` in `syncKeyPrompt.ts`, up to 3 tries, then `WrongKeyError`; cancelling gives `keyRequired`); a missing key on an empty target gives `keyMissing`. `connect` asks, in `chooseEncryption`: encrypted target = used as such, plain = no question, empty = encrypt yes/no, names, new master key twice (`askNewMasterKey`, `MIN_KEY_LENGTH`). Changed notes are read and sealed in memory before the upload. `tools/decrypt-repo.js` (Node alone) and `tools/decrypt-repo.ps1` (Windows PowerShell 5.1 or 7, scrypt and AES-GCM in C# inside the script, the key made once) are not shipped, open a cloned repository, and must stay in step with `SyncCrypto.ts` (same layout, scrypt parameters and authenticated data). **Key tools:** `SyncService.encryptRepository({ names, masterKey, dropHistory })` (plain target only: downloads every synced file, `EncryptionSupport.rewrite` seals all of them under a new salt/key and sends them, the header, the index and the removal of the plain names as ONE upload), `changeMasterKey({ masterKey, dropHistory })` (`EncryptionSupport.readEverything` opens every note, asking the old key when it is not kept, then `rewrite` with a new salt and removes the old sealed names; the old saved key is deleted) and `forgetKey()` (`EncryptionSupport.forgetKey` + dropping the wrapper cache); `dropHistory` is `upload(..., { keepLast: 1 })`, the user's explicit choice (`askDropHistory`). `adoptEncryption` stores the new `encryption` config and the new base from the hashes just sent. The new key is kept on this computer only after the upload worked. Commands `noteRoot.syncEncryptRepository` (shown while `noteRoot.syncEncrypted` is false), `syncChangeKey` and `syncForgetKey` (while it is true); `publishConnected` sets the context key. Not done: other computers are not told about a key change (they fail with a wrong key until the user connects again), and the older commits keep the old content unless the history is dropped. The logic was checked with a throwaway harness (the real `SyncService` against a fake GitHub with several computers, the commands with scripted answers, a second provider on a shared folder, and the encrypted sync including tampering and wrong keys); the real GitHub and the view need F5. @@ -108,6 +111,6 @@ media/ quickNote.css/js, noteroot.svg (Activity Bar), icon.png, walk - Names: ids `noteRoot.*`, config folder `.noteroot`, package `noteroot`, encrypted header `NOTEROOT-ENCRYPTED v1`, GitHub repository `talakbairam/NoteRoot`. - In the Windows Bash tool, heredocs and `sed` mangle backslashes. Write JSON, regexes and Windows paths with the Write or Edit tool, or use forward slashes. In `package.json`, `when` clauses need `\\.` in regexes. -- `.vscodeignore` ships only `dist/extension.js`, `media/**`, README, CHANGELOG, LICENSE and `package.json`. Check with `npx vsce ls`. +- `.vscodeignore` ships only `dist/extension.js`, `media/**`, `webview/**`, README, CHANGELOG, LICENSE and `package.json`. Check with `npx vsce ls`. - `when` clauses of `package.json` have `=~` but **no `!~`**. To say "does not contain" use a regex with a negative lookahead (`viewItem =~ /^noteRoot\.file(?!.*\.hidden)/`) so each pair of entries (Exclude / Include) is exclusive. - Update `README.md`, `CHANGELOG.md` and this file when behaviour changes. diff --git a/MANUAL-TESTING.md b/MANUAL-TESTING.md index fc30c37..02af54b 100644 --- a/MANUAL-TESTING.md +++ b/MANUAL-TESTING.md @@ -72,6 +72,13 @@ NoteRoot has no automated tests, so check these by hand before a release. Press - [ ] Every row opens the right editor (choice, text, number, list, switch, action) and rejects bad input with a message. - [ ] **Reset to Default** appears only on changed rows; the folder value is removed first, a second reset removes the user value. Rows show **· this folder** when the notes folder has its own value. - [ ] A change made in the VS Code Settings editor shows up in the panel at once. +- [ ] The pencil button of the Settings view opens **NoteRoot Settings** in an editor tab (a second click shows the same tab). Every kind of row can be changed there (select, checkbox, text box on Enter or leaving it, list with **Apply** or Ctrl+Enter, **Change…** for the folder and the Quick Note file); a bad value shows a message under the row and is not saved; **Reset** and the search box and **Only changed** work; a change made in the Settings view shows up in the page and the other way round. + +## Tags & Categories page + +- [ ] The pencil button of the Tags & Categories view opens **NoteRoot Tags & Categories** in an editor tab (a second click shows the same tab). Categories, tags, patterns and the **Every file** list match the view; the counts appear a moment after the lists. +- [ ] New, Rename, Keyword, Change file, Remove file / keyword and Delete ask the same questions as the view and the page updates at once; the two switches turn the automatic features on and off; a note, a category file and a tag of a pattern open from their links. +- [ ] A change made in the view, in the Quick Note panel or in the config file shows up in the page; with no notes folder the page offers **Select Notes Folder**. ## Encryption diff --git a/README.md b/README.md index acc2fd2..01ad825 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ Your notes are plain files in a folder you pick. NoteRoot is built so you can tr - **Sync.** Push your notes to GitHub and pull them on another computer, with no git and no other app. It can be encrypted. See below. - **Settings panel.** Every setting is in one panel, with its value: click a row to change it, right-click to reset it or show it in the VS Code Settings editor. Settings that belong to your notes are saved in the notes folder, so they travel with it (and with Sync); those are not in the VS Code Settings editor. The panel has four groups: +- **Settings page.** The pencil button of the Settings panel opens all settings in an editor tab, with search, an *Only changed* filter, defaults and Reset. +- **Tags & Categories page.** The pencil button of the Tags & Categories panel opens everything in an editor tab: categories, tags, tag patterns, the categories of every file and the two automatic switches, with their notes and files. - **Notes:** the notes folder, sort order, default extension, hidden files and folders, the large file warning, the note header and which file types count as notes. - **Quick Note:** the Quick Note file, where new notes go (start or end), the heading and date format above each note, the line with the tags, and *Tag when saving*. - **Tags & Categories:** the Advanced options, *Category for every file* and *Tag from pattern* switches (and whether they start on), and the files and folders that are never tagged automatically. diff --git a/package.json b/package.json index 885d9fd..83c2a1b 100644 --- a/package.json +++ b/package.json @@ -305,6 +305,11 @@ "title": "NoteRoot: Open Settings Editor", "icon": "$(settings-gear)" }, + { + "command": "noteRoot.openSettingsPage", + "title": "NoteRoot: Open Settings Page", + "icon": "$(edit)" + }, { "command": "noteRoot.syncConnect", "title": "NoteRoot: Connect to a Sync Service" @@ -532,6 +537,11 @@ "title": "Refresh", "icon": "$(refresh)" }, + { + "command": "noteRoot.openLabelsPage", + "title": "NoteRoot: Open Tags & Categories Page", + "icon": "$(edit)" + }, { "command": "noteRoot.openAtLine", "title": "Open at Line" @@ -654,6 +664,11 @@ "when": "view == noteRoot.labels && noteRoot.hasRoot", "group": "navigation@2" }, + { + "command": "noteRoot.openLabelsPage", + "when": "view == noteRoot.labels && noteRoot.hasRoot", + "group": "navigation@3" + }, { "command": "noteRoot.enableAutoCategories", "when": "view == noteRoot.labels && noteRoot.hasRoot && !noteRoot.autoCategories", @@ -675,10 +690,15 @@ "group": "navigation@1" }, { - "command": "noteRoot.openAllSettings", + "command": "noteRoot.openSettingsPage", "when": "view == noteRoot.settings", "group": "navigation@0" }, + { + "command": "noteRoot.openAllSettings", + "when": "view == noteRoot.settings", + "group": "navigation@1" + }, { "command": "noteRoot.syncRefresh", "when": "view == noteRoot.sync && noteRoot.syncConnected", diff --git a/src/commands/labelViewCommands.ts b/src/commands/labelViewCommands.ts index 14e5800..bfecfc8 100644 --- a/src/commands/labelViewCommands.ts +++ b/src/commands/labelViewCommands.ts @@ -7,6 +7,7 @@ import { LabelService } from "../services/LabelService"; import { Logger } from "../services/Logger"; import { PathGuard } from "../services/PathGuard"; import { LabelEntryItem, LabelsTreeProvider } from "../tree/LabelsTreeProvider"; +import type { LabelsPageProvider } from "../webview/LabelsPageProvider"; import { askCategoryFile } from "./labelDialogs"; import { performLabelAction } from "./labelCommands"; import { registerSafeCommand } from "./safe"; @@ -18,14 +19,23 @@ export interface LabelViewCommandDependencies { pathGuard: PathGuard; labelService: LabelService; labelsProvider: LabelsTreeProvider; + labelsPage: LabelsPageProvider; autoCategories: AutoCategoryService; advancedMode: AdvancedModeService; } /** The commands of the Tags & Categories view. They share their questions with the Manage list. */ export function registerLabelViewCommands(deps: LabelViewCommandDependencies): void { - const { context, logger, pathGuard, labelService, labelsProvider, autoCategories, advancedMode } = - deps; + const { + context, + logger, + pathGuard, + labelService, + labelsProvider, + labelsPage, + autoCategories, + advancedMode, + } = deps; const register = (id: string, handler: (...args: unknown[]) => unknown): void => registerSafeCommand(context, logger, id, handler); @@ -160,6 +170,7 @@ export function registerLabelViewCommands(deps: LabelViewCommandDependencies): v }); register(Commands.refreshLabels, () => labelsProvider.refresh()); + register(Commands.openLabelsPage, () => labelsPage.open()); // A click on a note of a tag: open it with the tag selected. register( diff --git a/src/commands/settingsCommands.ts b/src/commands/settingsCommands.ts index f1df82a..218c3c5 100644 --- a/src/commands/settingsCommands.ts +++ b/src/commands/settingsCommands.ts @@ -7,11 +7,13 @@ import { findSettingDef, isChanged, resetSetting, + typedValue, writeSwitch, type SettingDef, type SettingsContext, } from "../services/SettingsCatalog"; import { SettingItem, choiceLabel } from "../tree/SettingsTreeProvider"; +import type { SettingsPageProvider } from "../webview/SettingsPageProvider"; import { registerSafeCommand, reportError } from "./safe"; import { showStatus } from "../util/status"; @@ -19,6 +21,7 @@ export interface SettingsCommandDependencies { context: vscode.ExtensionContext; logger: Logger; settingsContext: SettingsContext; + settingsPage: SettingsPageProvider; } /** What the user asked for in the list of a setting. */ @@ -49,7 +52,7 @@ const listButtons: Record<"edit" | "delete", ListButton> = { /** The commands of the Settings view: change a setting, take it back to its default, show it in the Settings editor. */ export function registerSettingsCommands(deps: SettingsCommandDependencies): void { - const { context, logger, settingsContext } = deps; + const { context, logger, settingsContext, settingsPage } = deps; const { settings } = settingsContext; const text = Strings.settingsView; @@ -96,13 +99,7 @@ export function registerSettingsCommands(deps: SettingsCommandDependencies): voi if (typed === undefined || def.key === undefined) { return; } - // A date format may have spaces in it on purpose; everything else is taken as it is typed, without the spaces around it. - const next = - def.kind === "number" - ? (def.parse?.(typed) ?? Number(typed)) - : def.id === "quickNoteTimestampFormat" - ? typed - : typed.trim(); + const next = typedValue(def, typed); if (next !== def.read(settingsContext)) { await settings.setValue(def.key, next); say(text.updated(text.items[def.id].label)); @@ -229,6 +226,10 @@ export function registerSettingsCommands(deps: SettingsCommandDependencies): voi } }); + registerSafeCommand(context, logger, Commands.openSettingsPage, () => { + settingsPage.open(); + }); + registerSafeCommand(context, logger, Commands.openAllSettings, async () => { await vscode.commands.executeCommand(BuiltinCommands.openSettings, SETTINGS_SECTION); }); diff --git a/src/commands/tagPatternCommands.ts b/src/commands/tagPatternCommands.ts index 65dd3de..6c743fe 100644 --- a/src/commands/tagPatternCommands.ts +++ b/src/commands/tagPatternCommands.ts @@ -164,14 +164,19 @@ export function registerTagPatternCommands(deps: TagPatternCommandDependencies): registerSafeCommand(context, logger, Commands.addTagPattern, async () => { await perform({ type: "add" }, await labelService.getTagPatterns()); }); + // The Tags & Categories page runs the same two commands with the pattern as text. + const patternOf = (item: unknown): string | undefined => + item instanceof PatternNode ? item.pattern : typeof item === "string" ? item : undefined; registerSafeCommand(context, logger, Commands.editTagPattern, async (item?: unknown) => { - if (item instanceof PatternNode) { - await perform({ type: "edit", pattern: item.pattern }, await labelService.getTagPatterns()); + const pattern = patternOf(item); + if (pattern !== undefined) { + await perform({ type: "edit", pattern }, await labelService.getTagPatterns()); } }); registerSafeCommand(context, logger, Commands.deleteTagPattern, async (item?: unknown) => { - if (item instanceof PatternNode) { - await perform({ type: "delete", pattern: item.pattern }, await labelService.getTagPatterns()); + const pattern = patternOf(item); + if (pattern !== undefined) { + await perform({ type: "delete", pattern }, await labelService.getTagPatterns()); } }); diff --git a/src/constants/commands.ts b/src/constants/commands.ts index a6d556f..7f6bb13 100644 --- a/src/constants/commands.ts +++ b/src/constants/commands.ts @@ -76,6 +76,8 @@ export const Commands = { resetSetting: "noteRoot.resetSetting", showSettingInEditor: "noteRoot.showSettingInEditor", openAllSettings: "noteRoot.openAllSettings", + openSettingsPage: "noteRoot.openSettingsPage", + openLabelsPage: "noteRoot.openLabelsPage", tagNote: "noteRoot.tagNote", tagCurrentNote: "noteRoot.tagCurrentNote", insertHeader: "noteRoot.insertHeader", diff --git a/src/constants/strings.ts b/src/constants/strings.ts index e6c7013..2081652 100644 --- a/src/constants/strings.ts +++ b/src/constants/strings.ts @@ -77,6 +77,75 @@ export const Strings = { current: "current", }, + /** The Tags & Categories page: what the view has, in an editor tab, to review and change. */ + labelsPage: { + title: "NoteRoot Tags & Categories", + intro: + "Review and change your categories, tags and the automatic features. A change is saved as soon as you make it.", + refresh: "Refresh", + searchLabel: "Search categories, tags and patterns", + searchPlaceholder: "Search", + noMatches: "Nothing matches.", + needsAdvanced: + "The Advanced options of the Quick Note panel are off. Categories, tags and the automatic features only work while they are on.", + counting: "Counting…", + categories: "Categories", + newCategory: "New category…", + noCategories: "No categories yet.", + usesQuickNoteFile: "Quick Note file", + notCreated: "not created yet", + everyFile: "Every file", + everyFileSwitch: "Category for every file", + everyFileHint: "Every note file is a category that writes to that file.", + everyFileNone: "No note files yet.", + everyFileMore: "More files exist than are listed.", + tags: "Tags", + newTag: "New tag…", + noTags: "No tags yet.", + patterns: "Tag Patterns", + patternsSwitch: "Tag from pattern", + patternsHint: + "Text of a note that matches a pattern becomes a tag when the note is saved. The tag is written in the note only.", + newPattern: "Add pattern…", + noPatterns: "No patterns yet.", + noMatchesYet: "No matches yet.", + keyword: "Keyword", + changeFile: "Change file…", + removeFile: "Remove file", + setKeyword: "Keyword…", + removeKeyword: "Remove keyword", + rename: "Rename…", + delete: "Delete", + edit: "Change…", + noteOne: "1 note", + noteMany: "{n} notes", + tagOne: "1 tag", + tagMany: "{n} tags", + on: "On", + off: "Off", + }, + /** The Settings page: every setting in an editor tab, to review and change. */ + settingsPage: { + title: "NoteRoot Settings", + intro: "Review and change every NoteRoot setting. A change is saved as soon as you make it.", + searchLabel: "Search settings", + searchPlaceholder: "Search settings", + onlyChanged: "Only changed", + noMatches: "No settings match.", + changed: "changed", + sourceFolder: "this folder", + sourceUser: "your settings", + reset: "Reset", + change: "Change…", + apply: "Apply", + listHint: "One entry per line.", + on: "On", + off: "Off", + defaultNone: "none", + units: { mb: "MB", ms: "ms", px: "px", s: "s", count: "" }, + saved: "Saved", + defaultLabel: "Default:", + }, settingsView: { groups: { notes: "Notes", quickNote: "Quick Note", tags: "Tags & Categories", more: "More" }, items: { @@ -278,6 +347,7 @@ export const Strings = { invalidFormat: "Use one line of at most 60 characters.", invalidNumber: "Enter a number of 0 or more.", emptyNotAllowed: "This cannot be empty.", + invalidChoice: "This value is not allowed for this setting.", updated: (label: string): string => `${label} updated`, resetDone: (label: string): string => `${label} is back to its default`, listTitle: (label: string): string => `Change: ${label}`, diff --git a/src/extension.ts b/src/extension.ts index 4bfd03c..0543851 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -47,6 +47,8 @@ import { TreeNavigator } from "./tree/TreeNavigator"; import { TreeStateController } from "./tree/TreeStateController"; import { configureStatusMessages } from "./util/status"; import { QuickNoteViewProvider } from "./webview/QuickNoteViewProvider"; +import { LabelsPageProvider } from "./webview/LabelsPageProvider"; +import { SettingsPageProvider } from "./webview/SettingsPageProvider"; /** Wiring only: creates services and providers and registers them. No business logic here. */ export function activate(context: vscode.ExtensionContext): void { @@ -177,6 +179,7 @@ export function activate(context: vscode.ExtensionContext): void { syncProvider.attach(syncView); const settingsContext = { settings, advancedMode, autoCategories, autoTagPatterns }; const settingsProvider = new SettingsTreeProvider(settingsContext); + const settingsPage = new SettingsPageProvider(context.extensionUri, settingsContext, logger); const labelsProvider = new LabelsTreeProvider( labelService, tagIndex, @@ -187,6 +190,17 @@ export function activate(context: vscode.ExtensionContext): void { autoTagPatterns, logger ); + const labelsPage = new LabelsPageProvider( + context.extensionUri, + labelService, + labelsProvider, + rootService, + autoCategories, + autoTagPatterns, + advancedMode, + settings, + logger + ); const quickNoteService = new QuickNoteService( rootService, pathGuard, @@ -234,6 +248,8 @@ export function activate(context: vscode.ExtensionContext): void { syncProvider, syncView, settingsProvider, + settingsPage, + labelsPage, vscode.window.registerTreeDataProvider(ViewIds.settings, settingsProvider), vscode.window.registerTreeDataProvider(ViewIds.recent, pinnedRecentProvider), quickNoteService, @@ -265,6 +281,7 @@ export function activate(context: vscode.ExtensionContext): void { recentService, pinnedService, labelsProvider, + labelsPage, autoCategories, autoTagPatterns, taggingService, @@ -275,6 +292,7 @@ export function activate(context: vscode.ExtensionContext): void { excludeMatcher, noTagMatcher, settingsContext, + settingsPage, syncService, onlineView, }); diff --git a/src/services/SettingsCatalog.ts b/src/services/SettingsCatalog.ts index 63e46ce..2d931c1 100644 --- a/src/services/SettingsCatalog.ts +++ b/src/services/SettingsCatalog.ts @@ -419,6 +419,107 @@ export function findSettingDef(id: string): SettingDef | undefined { return SETTING_DEFS.find((def) => def.id === id); } +/** + * The value a typed text stands for. A date format may have spaces in it on purpose; everything else is taken as it + * is typed, without the spaces around it. + */ +export function typedValue(def: SettingDef, typed: string): string | number { + if (def.kind === "number") { + return def.parse?.(typed) ?? Number(typed); + } + return def.id === "quickNoteTimestampFormat" ? typed : typed.trim(); +} + +/** The entries of a list as they are stored, or a message about the first one that is not valid. */ +function checkEntries(def: SettingDef, entries: readonly string[]): string[] | string { + const text = Strings.settingsView; + const normalized: string[] = []; + for (const entry of entries) { + if (entry.trim() === "") { + return text.listEntryEmpty; + } + const message = def.validateEntry?.(entry); + if (message !== undefined) { + return message; + } + const next = def.normalizeEntry?.(entry) ?? entry.trim(); + if (!def.allowDuplicates && normalized.includes(next)) { + return text.listEntryDuplicate(next); + } + normalized.push(next); + } + return normalized; +} + +/** + * Checks `value` (from a page, so not trusted) for the setting and saves it with the same rules as the Settings view. + * Returns a message when the value is not valid, and nothing when it was saved (or was the value already). + * Throws what `SettingsService.setValue` throws (a damaged config file, no notes folder). + */ +export async function applySettingEdit( + context: SettingsContext, + def: SettingDef, + value: unknown +): Promise { + const text = Strings.settingsView; + switch (def.kind) { + case "switch": + case "toggle": { + if (typeof value !== "boolean") { + return text.invalidChoice; + } + if (value !== (def.read(context) === true)) { + if (def.kind === "switch") { + await writeSwitch(context, def.id, value); + } else if (def.key !== undefined) { + await context.settings.setValue(def.key, value); + } + } + return undefined; + } + case "choice": { + if (typeof value !== "string" || !(def.choices ?? []).includes(value)) { + return text.invalidChoice; + } + if (value !== String(def.read(context)) && def.key !== undefined) { + await context.settings.setValue(def.key, value); + } + return undefined; + } + case "text": + case "number": { + if (typeof value !== "string" || def.key === undefined) { + return text.invalidChoice; + } + const message = + value === "" && !def.allowEmpty ? text.emptyNotAllowed : def.validate?.(value); + if (message !== undefined) { + return message; + } + const next = typedValue(def, value); + if (next !== def.read(context)) { + await context.settings.setValue(def.key, next); + } + return undefined; + } + case "list": { + if (!Array.isArray(value) || def.key === undefined) { + return text.invalidChoice; + } + const entries = checkEntries(def, value as string[]); + if (typeof entries === "string") { + return entries; + } + if (JSON.stringify(entries) !== JSON.stringify(def.read(context))) { + await context.settings.setValue(def.key, entries); + } + return undefined; + } + case "action": + return text.invalidChoice; + } +} + /** Switches the setting named `id` on or off. Only for `switch` settings. */ export async function writeSwitch( context: SettingsContext, diff --git a/src/tree/LabelsTreeProvider.ts b/src/tree/LabelsTreeProvider.ts index 5418426..f4c2b17 100644 --- a/src/tree/LabelsTreeProvider.ts +++ b/src/tree/LabelsTreeProvider.ts @@ -409,6 +409,16 @@ export class LabelsTreeProvider implements vscode.TreeDataProvider, v ); } + /** The notes of every tag, from the scan that the tree also uses (kept until the next refresh). */ + tagNotes(): Promise> { + return this.currentScan(); + } + + /** What every tag pattern made in the notes, from the same kept scan. */ + patternMatches(): Promise> { + return this.currentPatternScan(); + } + /** What the patterns made in the notes, kept until the next refresh like the scan of the tags. */ private async currentPatternScan( known?: readonly string[] diff --git a/src/webview/LabelsPageProvider.ts b/src/webview/LabelsPageProvider.ts new file mode 100644 index 0000000..1073e97 --- /dev/null +++ b/src/webview/LabelsPageProvider.ts @@ -0,0 +1,384 @@ +import { randomBytes } from "crypto"; +import * as vscode from "vscode"; +import { Commands } from "../constants/commands"; +import { RootStates } from "../constants/context"; +import { Strings } from "../constants/strings"; +import { performLabelAction } from "../commands/labelCommands"; +import { reportError } from "../commands/safe"; +import type { AdvancedModeService } from "../services/AdvancedModeService"; +import type { AutoCategoryService } from "../services/AutoCategoryService"; +import type { AutoTagPatternService } from "../services/AutoTagPatternService"; +import { LabelService } from "../services/LabelService"; +import { Logger } from "../services/Logger"; +import { RootFolderService } from "../services/RootFolderService"; +import { SettingsService } from "../services/SettingsService"; +import type { TagNote } from "../services/TagIndexService"; +import { LabelsTreeProvider } from "../tree/LabelsTreeProvider"; +import { showStatus } from "../util/status"; +import { + parseLabelsPageMessage, + type ExtensionToLabelsPageMessage, + type LabelsPageModel, + type LabelsPageTexts, + type LabelsPageToExtensionMessage, + type PageNote, +} from "./labelsMessages"; + +const PANEL_TYPE = "noteRoot.labelsPage"; + +/** + * "NoteRoot Tags & Categories": everything the Tags & Categories view has, in an editor tab: the categories with their + * files and keywords, the categories of every file, the tags with the notes that use them, the tag patterns, and the two + * switches of the automatic features. The lists and the scan of the notes are the ones of the view (`LabelsTreeProvider`), + * and every change is made by the same code as the view's menus (`performLabelAction`, the pattern commands), so + * the questions and the rules are the same. The page only shows and asks; it never writes anything itself. + */ +export class LabelsPageProvider implements vscode.Disposable { + private panel: vscode.WebviewPanel | undefined; + /** True while the current page has announced itself with `ready`. */ + private ready = false; + /** The notes and files of the model last sent: the page names them by id or path and is not trusted with a path. */ + private notes = new Map(); + private files = new Set(); + /** Makes a newer model win over an older one that was still scanning. */ + private generation = 0; + private readonly subscriptions: vscode.Disposable[]; + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly labelService: LabelService, + private readonly labelsProvider: LabelsTreeProvider, + private readonly rootService: RootFolderService, + private readonly autoCategories: AutoCategoryService, + private readonly autoTagPatterns: AutoTagPatternService, + private readonly advancedMode: AdvancedModeService, + private readonly settings: SettingsService, + private readonly logger: Logger + ) { + // The view refreshes itself on every change of the lists, the notes folder, the files and the two switches. + this.subscriptions = [ + labelsProvider.onDidChangeTreeData(() => void this.push("update")), + advancedMode.onDidChange(() => void this.push("update")), + ]; + } + + /** Opens the page in a new tab, or shows the one that is open. */ + open(): void { + if (this.panel) { + this.panel.reveal(); + return; + } + const pagesRoot = vscode.Uri.joinPath(this.extensionUri, "webview"); + const panel = vscode.window.createWebviewPanel( + PANEL_TYPE, + Strings.labelsPage.title, + vscode.ViewColumn.Active, + { enableScripts: true, localResourceRoots: [pagesRoot] } + ); + this.panel = panel; + this.ready = false; + panel.webview.html = this.renderHtml(panel.webview, pagesRoot); + panel.webview.onDidReceiveMessage((raw: unknown) => { + const message = parseLabelsPageMessage(raw); + if (!message) { + this.logger.warn("Ignored an invalid message from the Tags & Categories page."); + return; + } + this.handleMessage(message).catch((error: unknown) => + reportError(this.logger, "Tags & Categories page", error) + ); + }); + panel.onDidDispose(() => { + if (this.panel === panel) { + this.panel = undefined; + this.ready = false; + } + }); + } + + dispose(): void { + this.panel?.dispose(); + vscode.Disposable.from(...this.subscriptions).dispose(); + } + + private async handleMessage(message: LabelsPageToExtensionMessage): Promise { + switch (message.type) { + case "ready": + return this.push("init"); + case "refresh": + this.labelsProvider.refresh(); + return; + case "selectRoot": + await vscode.commands.executeCommand(Commands.selectRootFolder); + return; + case "create": + await performLabelAction(this.labelService, { type: "create", kind: message.kind }); + return; + case "entry": + return this.entryAction(message); + case "pattern": + if (message.action === "add") { + await vscode.commands.executeCommand(Commands.addTagPattern); + } else { + await vscode.commands.executeCommand( + message.action === "edit" ? Commands.editTagPattern : Commands.deleteTagPattern, + message.pattern + ); + } + return; + case "switch": + await vscode.commands.executeCommand( + message.id === "autoCategories" + ? message.on + ? Commands.enableAutoCategories + : Commands.disableAutoCategories + : message.on + ? Commands.enableAutoTagPatterns + : Commands.disableAutoTagPatterns + ); + return; + case "openNote": { + const note = this.notes.get(message.id); + if (note) { + await vscode.commands.executeCommand( + Commands.openAtLine, + note.uri, + note.line, + note.column, + note.length + ); + } + return; + } + case "openFile": { + const root = this.rootService.getRootUri(); + if (root && this.files.has(message.path)) { + await vscode.commands.executeCommand( + Commands.openItem, + vscode.Uri.joinPath(root, ...message.path.split("/")) + ); + } + return; + } + } + } + + /** One action on a category or tag. The entry must exist now: the page may be a little behind. */ + private async entryAction( + message: Extract + ): Promise { + const labels = await this.labelService.getLabels(); + const { kind, name } = message; + const category = labels.categories.find((entry) => entry.name === name); + if (kind === "category" ? !category : !labels.tags.includes(name)) { + return; + } + const text = Strings.quickNote; + if (message.action === "removeFile") { + await this.labelService.setCategoryFile(name, undefined); + showStatus(text.categoryFileCleared(name)); + } else if (message.action === "removeKeyword") { + await this.labelService.setKeyword(kind, name, undefined); + showStatus(text.keywordCleared(kind, name)); + } else { + await performLabelAction( + this.labelService, + { type: message.action, kind, name, file: category?.file }, + labels + ); + } + } + + /** Messages for a page that is not ready are dropped: its `init` carries the current model anyway. */ + private post(message: ExtensionToLabelsPageMessage): void { + if (this.panel && this.ready) { + this.panel.webview + .postMessage(message) + .then(undefined, (error: unknown) => + this.logger.error("Could not post to the Tags & Categories page.", error) + ); + } + } + + /** + * Sends the model. The lists come first, at once; the notes of the tags need a scan of the notes folder, so a second model + * with them follows when it is done (the view keeps the scan until something changes, so a later look is quick). + */ + private async push(type: "init" | "update"): Promise { + if (type === "init") { + this.ready = true; + } + if (!this.panel || !this.ready) { + return; + } + const generation = ++this.generation; + try { + const quick = await this.buildModel(false); + if (generation !== this.generation) { + return; + } + this.send(type, quick); + if (quick.problem !== undefined) { + return; + } + const full = await this.buildModel(true); + if (generation === this.generation) { + this.send("update", full); + } + } catch (error) { + this.logger.warn( + `Could not read the tags and categories: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + private send(type: "init" | "update", model: LabelsPageModel): void { + this.post(type === "init" ? { type, texts: this.texts(), model } : { type, model }); + } + + private rootProblem(): string | undefined { + switch (this.rootService.state) { + case RootStates.noRoot: + return Strings.root.noRootSelected; + case RootStates.missing: + return Strings.root.notFound; + default: + return undefined; + } + } + + private async exists(relativePath: string): Promise { + const root = this.rootService.getRootUri(); + if (!root) { + return false; + } + try { + await vscode.workspace.fs.stat(vscode.Uri.joinPath(root, ...relativePath.split("/"))); + return true; + } catch { + return false; // Not there (yet): the first note of a category creates its file. + } + } + + private async buildModel(withNotes: boolean): Promise { + const quickNoteFile = this.settings.quickNoteFile.replace(/\\/g, "/").replace(/^(\.?\/)+/, ""); + const base: LabelsPageModel = { + problem: this.rootProblem(), + advanced: this.advancedMode.isEnabled, + autoCategories: this.autoCategories.isEnabled, + autoTagPatterns: this.autoTagPatterns.isEnabled, + scanning: !withNotes, + quickNoteFile, + quickNoteFileExists: false, + categories: [], + everyFile: [], + everyFileTruncated: false, + tags: [], + patterns: [], + }; + if (!this.rootService.hasRoot) { + return base; + } + const { categories, tags, tagKeywords } = await this.labelService.getLabels(); + const patterns = await this.labelService.getTagPatterns(); + const tagNotes = withNotes ? await this.labelsProvider.tagNotes() : undefined; + const patternMatches = withNotes ? await this.labelsProvider.patternMatches() : undefined; + + // The ids are made again with every model that has notes; the page is given only the ones of the model it shows. + const notes = new Map(); + const files = new Set(); + const pageNote = (note: TagNote): PageNote => { + const id = `n${notes.size}`; + notes.set(id, note); + return { id, path: note.relativePath, line: note.line + 1 }; + }; + + const stored = categories.map((category) => { + if (category.file) { + files.add(category.file); + } + return category; + }); + const fromFiles = (await this.labelService.getChoosableCategories()).slice(categories.length); + for (const category of fromFiles) { + if (category.file) { + files.add(category.file); + } + } + files.add(quickNoteFile); + + const model: LabelsPageModel = { + ...base, + quickNoteFileExists: await this.exists(quickNoteFile), + categories: await Promise.all( + stored.map(async (category) => ({ + name: category.name, + file: category.file, + keyword: category.keyword, + fileExists: category.file ? await this.exists(category.file) : false, + })) + ), + everyFile: fromFiles.flatMap((category) => + category.file ? [{ label: category.label ?? category.name, path: category.file }] : [] + ), + everyFileTruncated: this.autoCategories.truncated, + tags: tags.map((name) => ({ + name, + keyword: tagKeywords[name], + notes: tagNotes ? (tagNotes.get(name) ?? []).map(pageNote) : undefined, + })), + patterns: patterns.map((pattern) => ({ + pattern, + tags: patternMatches + ? (patternMatches.get(pattern) ?? []).map((found) => ({ + name: found.name, + notes: found.notes.map(pageNote), + })) + : undefined, + })), + }; + // Only a model with notes replaces the notes, so a click on a note of the page before it still finds it. + if (withNotes) { + this.notes = notes; + } + this.files = files; + return model; + } + + private texts(): LabelsPageTexts { + const text = Strings.labelsPage; + return { ...text, selectFolder: Strings.root.selectFolderAction }; + } + + private renderHtml(webview: vscode.Webview, pagesRoot: vscode.Uri): string { + const nonce = randomBytes(16).toString("base64"); + const styleUri = webview.asWebviewUri( + vscode.Uri.joinPath(pagesRoot, "labelsPage", "labelsPage.css") + ); + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(pagesRoot, "labelsPage", "labelsPage.js") + ); + return ` + + + + + + + ${escapeHtml(Strings.labelsPage.title)} + + +
+ + +`; + } +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/src/webview/QuickNoteViewProvider.ts b/src/webview/QuickNoteViewProvider.ts index 9638055..7d03d6c 100644 --- a/src/webview/QuickNoteViewProvider.ts +++ b/src/webview/QuickNoteViewProvider.ts @@ -75,9 +75,9 @@ export class QuickNoteViewProvider implements vscode.WebviewViewProvider, vscode resolveWebviewView(webviewView: vscode.WebviewView): void { this.view = webviewView; this.ready = false; - const mediaRoot = vscode.Uri.joinPath(this.extensionUri, "media"); - webviewView.webview.options = { enableScripts: true, localResourceRoots: [mediaRoot] }; - webviewView.webview.html = this.renderHtml(webviewView.webview, mediaRoot); + const pagesRoot = vscode.Uri.joinPath(this.extensionUri, "webview"); + webviewView.webview.options = { enableScripts: true, localResourceRoots: [pagesRoot] }; + webviewView.webview.html = this.renderHtml(webviewView.webview, pagesRoot); webviewView.description = this.quickNoteService.getTargetPath(); webviewView.webview.onDidReceiveMessage((raw: unknown) => { @@ -391,10 +391,14 @@ export class QuickNoteViewProvider implements vscode.WebviewViewProvider, vscode } } - private renderHtml(webview: vscode.Webview, mediaRoot: vscode.Uri): string { + private renderHtml(webview: vscode.Webview, pagesRoot: vscode.Uri): string { const nonce = randomBytes(16).toString("base64"); - const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, "quickNote.css")); - const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, "quickNote.js")); + const styleUri = webview.asWebviewUri( + vscode.Uri.joinPath(pagesRoot, "quickNote", "quickNote.css") + ); + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(pagesRoot, "quickNote", "quickNote.js") + ); const text = Strings.quickNote; return ` diff --git a/src/webview/SettingsPageProvider.ts b/src/webview/SettingsPageProvider.ts new file mode 100644 index 0000000..9a25f69 --- /dev/null +++ b/src/webview/SettingsPageProvider.ts @@ -0,0 +1,235 @@ +import { randomBytes } from "crypto"; +import * as vscode from "vscode"; +import { isFolderSettingKey } from "../constants/settings"; +import { Strings } from "../constants/strings"; +import { Logger } from "../services/Logger"; +import { + SETTING_DEFS, + applySettingEdit, + findSettingDef, + isChanged, + resetSetting, + type SettingDef, + type SettingsContext, +} from "../services/SettingsCatalog"; +import { reportError } from "../commands/safe"; +import { choiceLabel, describeValue } from "../tree/SettingsTreeProvider"; +import { showStatus } from "../util/status"; +import { + parseSettingsPageMessage, + type ExtensionToSettingsPageMessage, + type SettingRow, + type SettingsPageTexts, + type SettingsPageToExtensionMessage, +} from "./settingsMessages"; + +const PANEL_TYPE = "noteRoot.settingsPage"; + +/** + * "NoteRoot Settings": every setting in a tab of the editor, to review and change. It uses the same list + * (`SETTING_DEFS`) and the same checks and writing (`applySettingEdit`, `resetSetting`) as the Settings view, so the two + * cannot disagree, and it follows the settings, so a change made anywhere shows up at once. + */ +export class SettingsPageProvider implements vscode.Disposable { + private panel: vscode.WebviewPanel | undefined; + /** True while the current page has announced itself with `ready`. */ + private ready = false; + private readonly subscriptions: vscode.Disposable[]; + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly context: SettingsContext, + private readonly logger: Logger + ) { + const refresh = (): void => this.post({ type: "update", rows: this.rows() }); + this.subscriptions = [ + context.settings.onDidChange(refresh), + context.advancedMode.onDidChange(refresh), + context.autoCategories.onDidChange(refresh), + context.autoTagPatterns.onDidChange(refresh), + ]; + } + + /** Opens the page in a new tab, or shows the one that is open. */ + open(): void { + if (this.panel) { + this.panel.reveal(); + return; + } + const pagesRoot = vscode.Uri.joinPath(this.extensionUri, "webview"); + const panel = vscode.window.createWebviewPanel( + PANEL_TYPE, + Strings.settingsPage.title, + vscode.ViewColumn.Active, + { enableScripts: true, localResourceRoots: [pagesRoot] } + ); + this.panel = panel; + this.ready = false; + panel.webview.html = this.renderHtml(panel.webview, pagesRoot); + panel.webview.onDidReceiveMessage((raw: unknown) => { + const message = parseSettingsPageMessage(raw); + if (!message) { + this.logger.warn("Ignored an invalid message from the Settings page."); + return; + } + this.handleMessage(message).catch((error: unknown) => + this.logger.error("Settings page message failed.", error) + ); + }); + panel.onDidDispose(() => { + if (this.panel === panel) { + this.panel = undefined; + this.ready = false; + } + }); + } + + dispose(): void { + this.panel?.dispose(); + vscode.Disposable.from(...this.subscriptions).dispose(); + } + + private async handleMessage(message: SettingsPageToExtensionMessage): Promise { + if (message.type === "ready") { + this.ready = true; + this.post({ type: "init", texts: this.texts(), rows: this.rows() }); + return; + } + const def = findSettingDef(message.id); + if (!def) { + return; + } + const label = Strings.settingsView.items[def.id].label; + try { + if (message.type === "edit") { + const error = await applySettingEdit(this.context, def, message.value); + this.post({ type: "result", id: def.id, error }); + if (error === undefined) { + showStatus(Strings.settingsView.updated(label)); + } + } else if (message.type === "reset") { + if (isChanged(this.context, def)) { + await resetSetting(this.context, def); + showStatus(Strings.settingsView.resetDone(label)); + } + } else if (def.kind === "action" && def.command !== undefined) { + await vscode.commands.executeCommand(def.command); + } + } catch (error) { + // A settings file that cannot be written: tell the user, and show the real values again. + reportError(this.logger, "Changing a setting", error); + this.post({ type: "update", rows: this.rows() }); + } + } + + /** Messages for a page that is not ready are dropped: its `init` carries the current rows anyway. */ + private post(message: ExtensionToSettingsPageMessage): void { + if (this.panel && this.ready) { + this.panel.webview + .postMessage(message) + .then(undefined, (error: unknown) => + this.logger.error("Could not post to the Settings page.", error) + ); + } + } + + private texts(): SettingsPageTexts { + const text = Strings.settingsPage; + return { + title: text.title, + intro: text.intro, + searchLabel: text.searchLabel, + searchPlaceholder: text.searchPlaceholder, + onlyChanged: text.onlyChanged, + noMatches: text.noMatches, + changed: text.changed, + sourceFolder: text.sourceFolder, + sourceUser: text.sourceUser, + reset: text.reset, + change: text.change, + apply: text.apply, + listHint: text.listHint, + on: text.on, + off: text.off, + saved: text.saved, + defaultLabel: text.defaultLabel, + }; + } + + private rows(): SettingRow[] { + return SETTING_DEFS.map((def) => this.rowOf(def)); + } + + private rowOf(def: SettingDef): SettingRow { + const text = Strings.settingsView.items[def.id]; + const value = def.read(this.context); + const source = + def.key !== undefined && isFolderSettingKey(def.key) + ? this.context.settings.sourceOf(def.key) + : undefined; + return { + id: def.id, + group: def.group, + groupLabel: Strings.settingsView.groups[def.group], + kind: def.kind, + label: text.label, + description: text.description, + value: + typeof value === "number" ? String(value) : typeof value === "object" ? [...value] : value, + valueText: describeValue(def, value), + defaultText: this.defaultText(def), + changed: isChanged(this.context, def), + source, + choices: + def.kind === "choice" + ? (def.choices ?? []).map((choice) => ({ + value: choice, + label: choiceLabel(def, choice), + })) + : undefined, + unit: def.unit ? Strings.settingsPage.units[def.unit] : "", + resettable: def.resettable, + }; + } + + /** The default as the page shows it: the entries of a list, one after the other, instead of a count. */ + private defaultText(def: SettingDef): string { + if (def.kind === "list") { + const entries = def.defaultValue as readonly string[]; + return entries.length === 0 ? Strings.settingsPage.defaultNone : entries.join(" | "); + } + return describeValue(def, def.defaultValue); + } + + private renderHtml(webview: vscode.Webview, pagesRoot: vscode.Uri): string { + const nonce = randomBytes(16).toString("base64"); + const styleUri = webview.asWebviewUri( + vscode.Uri.joinPath(pagesRoot, "settingsPage", "settingsPage.css") + ); + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(pagesRoot, "settingsPage", "settingsPage.js") + ); + return ` + + + + + + + ${escapeHtml(Strings.settingsPage.title)} + + +
+ + +`; + } +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/src/webview/labelsMessages.ts b/src/webview/labelsMessages.ts new file mode 100644 index 0000000..b81d66e --- /dev/null +++ b/src/webview/labelsMessages.ts @@ -0,0 +1,186 @@ +/** Message contract between the extension and the Tags & Categories page. Keep webview/labelsPage/labelsPage.js in sync. */ + +const MAX_ID_LENGTH = 100; +/** Longest name or pattern accepted: the page is not trusted. */ +const MAX_NAME_LENGTH = 300; + +export type LabelsPageKind = "category" | "tag"; + +export type LabelsPageToExtensionMessage = + | { type: "ready" } + | { type: "refresh" } + | { type: "selectRoot" } + | { type: "create"; kind: LabelsPageKind } + | { + type: "entry"; + action: "rename" | "delete" | "file" | "removeFile" | "keyword" | "removeKeyword"; + kind: LabelsPageKind; + name: string; + } + | { type: "pattern"; action: "add" } + | { type: "pattern"; action: "edit" | "delete"; pattern: string } + | { type: "switch"; id: "autoCategories" | "autoTagPatterns"; on: boolean } + /** `id` is one of the ids of the notes in the model: the page never sends a path. */ + | { type: "openNote"; id: string } + /** `path` must be one of the files in the model. */ + | { type: "openFile"; path: string }; + +/** A note that uses a tag, as the page lists it. */ +export interface PageNote { + id: string; + path: string; + /** 1-based. */ + line: number; +} + +export interface PageCategory { + name: string; + /** The file of the category; without it the category uses the Quick Note file. */ + file?: string; + keyword?: string; + /** False while the file does not exist yet (the first note creates it). */ + fileExists: boolean; +} + +export interface PageTag { + name: string; + keyword?: string; + /** Not known while the notes are still being scanned. */ + notes?: PageNote[]; +} + +export interface PagePattern { + pattern: string; + /** The tags the pattern made in notes. Not known while the notes are still being scanned. */ + tags?: { name: string; notes: PageNote[] }[]; +} + +export interface LabelsPageModel { + /** Why there is nothing to show (no notes folder, or it is gone), or nothing. */ + problem?: string; + /** The Advanced options of the Quick Note panel: categories and the automatic features only work while they are on. */ + advanced: boolean; + autoCategories: boolean; + autoTagPatterns: boolean; + /** True while the notes are scanned for the tags: the counts and the notes come with the next model. */ + scanning: boolean; + quickNoteFile: string; + quickNoteFileExists: boolean; + categories: PageCategory[]; + /** The categories made from files while "every file" is on. */ + everyFile: { label: string; path: string }[]; + everyFileTruncated: boolean; + tags: PageTag[]; + patterns: PagePattern[]; +} + +export interface LabelsPageTexts { + title: string; + intro: string; + refresh: string; + searchLabel: string; + searchPlaceholder: string; + noMatches: string; + selectFolder: string; + needsAdvanced: string; + counting: string; + categories: string; + newCategory: string; + noCategories: string; + usesQuickNoteFile: string; + notCreated: string; + everyFile: string; + everyFileSwitch: string; + everyFileHint: string; + everyFileNone: string; + everyFileMore: string; + tags: string; + newTag: string; + noTags: string; + patterns: string; + patternsSwitch: string; + patternsHint: string; + newPattern: string; + noPatterns: string; + noMatchesYet: string; + keyword: string; + changeFile: string; + removeFile: string; + setKeyword: string; + removeKeyword: string; + rename: string; + delete: string; + edit: string; + noteOne: string; + noteMany: string; + tagOne: string; + tagMany: string; + on: string; + off: string; +} + +export type ExtensionToLabelsPageMessage = + | { type: "init"; texts: LabelsPageTexts; model: LabelsPageModel } + | { type: "update"; model: LabelsPageModel }; + +/** Page content is untrusted input: check type and size of everything before using it. */ +export function parseLabelsPageMessage(value: unknown): LabelsPageToExtensionMessage | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + const message = value as Record; + const isName = (field: unknown): field is string => + typeof field === "string" && field.length > 0 && field.length <= MAX_NAME_LENGTH; + const isKind = (field: unknown): field is LabelsPageKind => + field === "category" || field === "tag"; + + switch (message.type) { + case "ready": + return { type: "ready" }; + case "refresh": + return { type: "refresh" }; + case "selectRoot": + return { type: "selectRoot" }; + case "create": + return isKind(message.kind) ? { type: "create", kind: message.kind } : undefined; + case "entry": { + const actions = ["rename", "delete", "file", "removeFile", "keyword", "removeKeyword"]; + return typeof message.action === "string" && + actions.includes(message.action) && + isKind(message.kind) && + isName(message.name) + ? { + type: "entry", + action: message.action as Extract< + LabelsPageToExtensionMessage, + { type: "entry" } + >["action"], + kind: message.kind, + name: message.name, + } + : undefined; + } + case "pattern": + if (message.action === "add") { + return { type: "pattern", action: "add" }; + } + return (message.action === "edit" || message.action === "delete") && isName(message.pattern) + ? { type: "pattern", action: message.action, pattern: message.pattern } + : undefined; + case "switch": + return (message.id === "autoCategories" || message.id === "autoTagPatterns") && + typeof message.on === "boolean" + ? { type: "switch", id: message.id, on: message.on } + : undefined; + case "openNote": + return typeof message.id === "string" && + message.id.length > 0 && + message.id.length <= MAX_ID_LENGTH + ? { type: "openNote", id: message.id } + : undefined; + case "openFile": + return isName(message.path) ? { type: "openFile", path: message.path } : undefined; + default: + return undefined; + } +} diff --git a/src/webview/messages.ts b/src/webview/messages.ts index 1f0e857..2c3fcbd 100644 --- a/src/webview/messages.ts +++ b/src/webview/messages.ts @@ -1,4 +1,4 @@ -/** Message contract between the extension and the Quick Note webview. Keep media/quickNote.js in sync. */ +/** Message contract between the extension and the Quick Note webview. Keep webview/quickNote/quickNote.js in sync. */ /** Longest note or draft accepted. The textarea enforces the same limit with `maxlength`. */ export const MAX_TEXT_LENGTH = 1_000_000; diff --git a/src/webview/settingsMessages.ts b/src/webview/settingsMessages.ts new file mode 100644 index 0000000..b1a20b8 --- /dev/null +++ b/src/webview/settingsMessages.ts @@ -0,0 +1,107 @@ +/** Message contract between the extension and the Settings page. Keep webview/settingsPage/settingsPage.js in sync. */ + +const MAX_ID_LENGTH = 100; +/** Longest text or list entry accepted: the page is not trusted. */ +const MAX_VALUE_LENGTH = 1000; +const MAX_LIST_ENTRIES = 200; + +/** What the page edits with: a text, a switch or the entries of a list. */ +export type SettingEditValue = string | boolean | string[]; + +export type SettingsPageToExtensionMessage = + | { type: "ready" } + | { type: "edit"; id: string; value: SettingEditValue } + | { type: "reset"; id: string } + | { type: "action"; id: string }; + +/** One setting as the page shows it. */ +export interface SettingRow { + id: string; + group: string; + groupLabel: string; + kind: "choice" | "text" | "number" | "list" | "action" | "switch" | "toggle"; + label: string; + description: string; + /** The value now: a text, a number written as text, a switch, or the entries of a list. */ + value: SettingEditValue; + /** The value as the Settings view writes it (a label for a choice, a count for a list). */ + valueText: string; + defaultText: string; + changed: boolean; + /** Only for a setting that can be kept in the notes folder. */ + source?: "folder" | "user" | "default"; + choices?: { value: string; label: string }[]; + /** What a number counts (MB, ms...), or empty. */ + unit: string; + resettable: boolean; +} + +export interface SettingsPageTexts { + title: string; + intro: string; + searchLabel: string; + searchPlaceholder: string; + onlyChanged: string; + noMatches: string; + changed: string; + sourceFolder: string; + sourceUser: string; + reset: string; + change: string; + apply: string; + listHint: string; + on: string; + off: string; + saved: string; + defaultLabel: string; +} + +export type ExtensionToSettingsPageMessage = + | { type: "init"; texts: SettingsPageTexts; rows: SettingRow[] } + /** The settings changed (anywhere): the same rows again, with the new values. */ + | { type: "update"; rows: SettingRow[] } + /** The answer to an edit: `error` is why the value was not saved. */ + | { type: "result"; id: string; error?: string }; + +/** Page content is untrusted input: check type and size of everything before using it. */ +export function parseSettingsPageMessage( + value: unknown +): SettingsPageToExtensionMessage | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + const message = value as Record; + const hasId = + typeof message.id === "string" && message.id.length > 0 && message.id.length <= MAX_ID_LENGTH; + switch (message.type) { + case "ready": + return { type: "ready" }; + case "reset": + return hasId ? { type: "reset", id: message.id as string } : undefined; + case "action": + return hasId ? { type: "action", id: message.id as string } : undefined; + case "edit": { + if (!hasId) { + return undefined; + } + const edited = message.value; + const id = message.id as string; + if (typeof edited === "boolean") { + return { type: "edit", id, value: edited }; + } + if (typeof edited === "string" && edited.length <= MAX_VALUE_LENGTH) { + return { type: "edit", id, value: edited }; + } + if ( + Array.isArray(edited) && + edited.length <= MAX_LIST_ENTRIES && + edited.every((entry) => typeof entry === "string" && entry.length <= MAX_VALUE_LENGTH) + ) { + return { type: "edit", id, value: [...(edited as string[])] }; + } + return undefined; + } + default: + return undefined; + } +} diff --git a/webview/labelsPage/labelsPage.css b/webview/labelsPage/labelsPage.css new file mode 100644 index 0000000..4db3dae --- /dev/null +++ b/webview/labelsPage/labelsPage.css @@ -0,0 +1,256 @@ +/* Theme variables only, so every light, dark and high contrast theme works. */ + +body { + padding: 16px 24px 40px; + color: var(--vscode-foreground); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); +} + +main { + max-width: 860px; + margin: 0 auto; +} + +h1 { + margin: 0 0 4px; + font-size: 1.5em; + font-weight: 600; +} + +.intro { + margin: 0 0 12px; + color: var(--vscode-descriptionForeground); +} + +.notice { + margin: 0 0 12px; + padding: 6px 10px; + border-left: 3px solid var(--vscode-editorWarning-foreground, var(--vscode-focusBorder)); + background: var(--vscode-textBlockQuote-background, transparent); +} + +.problem { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-top: 16px; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding: 8px 0; +} + +.toolbar input[type='search'] { + flex: 1 1 220px; + min-width: 0; +} + +input[type='search'] { + box-sizing: border-box; + padding: 4px 8px; + color: var(--vscode-input-foreground); + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, var(--vscode-widget-border, transparent)); + border-radius: 2px; + font-family: inherit; + font-size: inherit; +} + +input:focus-visible, +button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +section { + margin-top: 24px; +} + +.section-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 14px; + padding-bottom: 4px; + border-bottom: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, transparent)); +} + +h2 { + margin: 0; + font-size: 1.15em; + font-weight: 600; +} + +.count { + color: var(--vscode-descriptionForeground); +} + +.spacer { + flex: 1 1 auto; +} + +.switch { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.hint, +.empty, +.meta { + color: var(--vscode-descriptionForeground); +} + +.hint { + margin: 6px 0 0; +} + +.empty { + margin: 8px 0 0; +} + +.item { + padding: 4px 0; + border-bottom: 1px solid var(--vscode-widget-border, transparent); +} + +.item-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; + min-height: 26px; +} + +.item-main { + display: flex; + flex: 1 1 260px; + flex-wrap: wrap; + align-items: baseline; + gap: 2px 10px; + min-width: 0; +} + +.name { + font-weight: 600; + overflow-wrap: anywhere; +} + +.badge { + padding: 0 6px; + color: var(--vscode-badge-foreground); + background: var(--vscode-badge-background); + border-radius: 8px; + font-size: 0.85em; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 2px 10px; +} + +.children { + margin: 2px 0 4px 26px; +} + +.children[hidden] { + display: none; +} + +.sub { + margin: 2px 0; +} + +.toggle { + flex: 0 0 auto; + width: 20px; + height: 20px; + padding: 0; + color: var(--vscode-foreground); + background: none; + border: none; + border-radius: 3px; + line-height: 1; + cursor: pointer; +} + +.toggle:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.3)); +} + +.toggle::before { + display: block; + width: 5px; + height: 5px; + margin: 0 auto; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + content: ''; + transform: rotate(-45deg); +} + +.toggle[aria-expanded='true']::before { + transform: rotate(45deg); +} + +.toggle.none { + visibility: hidden; +} + +button { + padding: 4px 12px; + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); + border: 1px solid var(--vscode-button-border, var(--vscode-contrastBorder, transparent)); + border-radius: 2px; + font-family: inherit; + font-size: inherit; + cursor: pointer; +} + +button:hover:not(:disabled) { + background: var(--vscode-button-hoverBackground); +} + +button.secondary { + color: var(--vscode-button-secondaryForeground); + background: var(--vscode-button-secondaryBackground); +} + +button.secondary:hover:not(:disabled) { + background: var(--vscode-button-secondaryHoverBackground); +} + +button.link { + padding: 0; + color: var(--vscode-textLink-foreground); + background: none; + border: none; + text-align: left; + text-decoration: underline; +} + +button.link:hover:not(:disabled) { + color: var(--vscode-textLink-activeForeground); + background: none; +} + +button.link.danger { + color: var(--vscode-errorForeground); +} + +#none { + margin-top: 24px; + color: var(--vscode-descriptionForeground); +} + +#none[hidden] { + display: none; +} diff --git a/webview/labelsPage/labelsPage.js b/webview/labelsPage/labelsPage.js new file mode 100644 index 0000000..363ffc0 --- /dev/null +++ b/webview/labelsPage/labelsPage.js @@ -0,0 +1,368 @@ +// The Tags & Categories page. All texts and data come from the extension (see src/webview/labelsMessages.ts); +// nothing is written from here: every change is a message, and the extension asks, checks and saves. +(function () { + 'use strict'; + + const vscode = acquireVsCodeApi(); + const page = document.getElementById('page'); + + let texts = null; + let model = null; + /** Keys of the rows that are open, so a new model (a change anywhere) does not close them. */ + const openKeys = new Set(); + let searchBox = null; + let content = null; + + function el(tag, className, text) { + const created = document.createElement(tag); + if (className) { + created.className = className; + } + if (text !== undefined) { + created.textContent = text; + } + return created; + } + + function send(message) { + vscode.postMessage(message); + } + + /** A text button that looks like a link. */ + function link(label, onClick, className) { + const button = el('button', 'link' + (className ? ' ' + className : ''), label); + button.type = 'button'; + button.addEventListener('click', onClick); + return button; + } + + function count(number, one, many) { + return number === 1 ? one : many.replace('{n}', String(number)); + } + + const notesText = (number) => count(number, texts.noteOne, texts.noteMany); + const tagsText = (number) => count(number, texts.tagOne, texts.tagMany); + + /** A row that can be opened: the arrow and the key of the open state. */ + function toggle(key, children, hasChildren) { + const button = el('button', 'toggle' + (hasChildren ? '' : ' none')); + button.type = 'button'; + button.setAttribute('aria-expanded', String(openKeys.has(key))); + button.tabIndex = hasChildren ? 0 : -1; + button.addEventListener('click', () => { + const open = !openKeys.has(key); + if (open) { + openKeys.add(key); + } else { + openKeys.delete(key); + } + button.setAttribute('aria-expanded', String(open)); + children.hidden = !open; + }); + children.hidden = !openKeys.has(key); + return button; + } + + function noteLinks(notes) { + const box = el('div', 'children'); + for (const note of notes) { + const row = el('div', 'sub'); + row.append(link(note.path + ':' + note.line, () => send({ type: 'openNote', id: note.id }))); + box.append(row); + } + return box; + } + + function matches(needle, ...parts) { + return needle === '' || parts.some((part) => part && part.toLowerCase().includes(needle)); + } + + function sectionHead(title, total, ...extra) { + const head = el('div', 'section-head'); + head.append(el('h2', undefined, title)); + if (total !== undefined) { + head.append(el('span', 'count', String(total))); + } + head.append(el('span', 'spacer'), ...extra); + return head; + } + + function switchBox(label, on, id) { + const wrapper = el('label', 'switch'); + const box = el('input'); + box.type = 'checkbox'; + box.checked = on; + box.addEventListener('change', () => send({ type: 'switch', id, on: box.checked })); + wrapper.append(box, document.createTextNode(label)); + return wrapper; + } + + function addButton(label, message) { + const button = el('button', 'secondary', label); + button.type = 'button'; + button.addEventListener('click', () => send(message)); + return button; + } + + function entryActions(kind, name, buttons) { + const actions = el('div', 'actions'); + for (const [label, action, danger] of buttons) { + actions.append(link(label, () => send({ type: 'entry', action, kind, name }), danger ? 'danger' : '')); + } + return actions; + } + + function keywordBadge(keyword) { + return keyword ? el('span', 'badge', texts.keyword + ': ' + keyword) : null; + } + + function categoryRow(category) { + const item = el('div', 'item'); + const head = el('div', 'item-head'); + const key = 'category:' + category.name; + const children = el('div', 'children'); + const file = category.file || model.quickNoteFile; + const exists = category.file ? category.fileExists : model.quickNoteFileExists; + const fileRow = el('div', 'sub'); + if (exists) { + fileRow.append(link(file, () => send({ type: 'openFile', path: file }))); + } else { + fileRow.append(el('span', undefined, file), el('span', 'meta', ' · ' + texts.notCreated)); + } + children.append(fileRow); + + const main = el('div', 'item-main'); + main.append(el('span', 'name', category.name)); + main.append(el('span', 'meta', category.file ? category.file : texts.usesQuickNoteFile)); + const badge = keywordBadge(category.keyword); + if (badge) { + main.append(badge); + } + const buttons = [[texts.changeFile, 'file']]; + if (category.file) { + buttons.push([texts.removeFile, 'removeFile']); + } + buttons.push([texts.setKeyword, 'keyword']); + if (category.keyword) { + buttons.push([texts.removeKeyword, 'removeKeyword']); + } + buttons.push([texts.rename, 'rename'], [texts.delete, 'delete', true]); + head.append(toggle(key, children, true), main, entryActions('category', category.name, buttons)); + item.append(head, children); + return item; + } + + function tagRow(tag) { + const item = el('div', 'item'); + const head = el('div', 'item-head'); + const notes = tag.notes; + const children = notes ? noteLinks(notes) : el('div', 'children'); + const main = el('div', 'item-main'); + main.append(el('span', 'name', '#' + tag.name)); + main.append(el('span', 'meta', notes ? notesText(notes.length) : texts.counting)); + const badge = keywordBadge(tag.keyword); + if (badge) { + main.append(badge); + } + const buttons = [[texts.setKeyword, 'keyword']]; + if (tag.keyword) { + buttons.push([texts.removeKeyword, 'removeKeyword']); + } + buttons.push([texts.rename, 'rename'], [texts.delete, 'delete', true]); + head.append( + toggle('tag:' + tag.name, children, !!notes && notes.length > 0), + main, + entryActions('tag', tag.name, buttons) + ); + item.append(head, children); + return item; + } + + function patternRow(pattern) { + const item = el('div', 'item'); + const head = el('div', 'item-head'); + const found = pattern.tags; + const children = el('div', 'children'); + if (found && found.length === 0) { + children.append(el('div', 'meta', texts.noMatchesYet)); + } else if (found) { + for (const tag of found) { + const row = el('div', 'sub'); + const key = 'pattern:' + pattern.pattern + ':' + tag.name.toLowerCase(); + const notes = noteLinks(tag.notes); + const line = el('div', 'item-head'); + const main = el('div', 'item-main'); + main.append(el('span', 'name', '#' + tag.name), el('span', 'meta', notesText(tag.notes.length))); + line.append(toggle(key, notes, tag.notes.length > 0), main); + row.append(line, notes); + children.append(row); + } + } + const main = el('div', 'item-main'); + main.append(el('span', 'name', pattern.pattern)); + main.append(el('span', 'meta', found ? tagsText(found.length) : texts.counting)); + const actions = el('div', 'actions'); + actions.append( + link(texts.edit, () => send({ type: 'pattern', action: 'edit', pattern: pattern.pattern })), + link(texts.delete, () => send({ type: 'pattern', action: 'delete', pattern: pattern.pattern }), 'danger') + ); + head.append(toggle('pattern:' + pattern.pattern, children, true), main, actions); + item.append(head, children); + return item; + } + + function everyFileRows(items, truncated) { + const box = el('div'); + if (items.length === 0) { + box.append(el('p', 'empty', texts.everyFileNone)); + return box; + } + for (const entry of items) { + const row = el('div', 'item'); + const head = el('div', 'item-head'); + const main = el('div', 'item-main'); + main.append(el('span', 'name', entry.label)); + main.append(link(entry.path, () => send({ type: 'openFile', path: entry.path }))); + head.append(main); + row.append(head); + box.append(row); + } + if (truncated) { + box.append(el('p', 'hint', texts.everyFileMore)); + } + return box; + } + + function render() { + content.textContent = ''; + if (model.problem) { + const problem = el('div', 'problem'); + const button = el('button', 'secondary', texts.selectFolder); + button.type = 'button'; + button.addEventListener('click', () => send({ type: 'selectRoot' })); + problem.append(el('p', undefined, model.problem), button); + content.append(problem); + return; + } + const needle = searchBox.value.trim().toLowerCase(); + let shown = 0; + + if (!model.advanced) { + content.append(el('p', 'notice', texts.needsAdvanced)); + } + + // Categories + const categories = model.categories.filter((c) => matches(needle, c.name, c.file, c.keyword)); + const categorySection = el('section'); + categorySection.append( + sectionHead( + texts.categories, + model.categories.length, + addButton(texts.newCategory, { type: 'create', kind: 'category' }) + ) + ); + if (model.categories.length === 0) { + categorySection.append(el('p', 'empty', texts.noCategories)); + } + for (const category of categories) { + categorySection.append(categoryRow(category)); + } + shown += categories.length; + content.append(categorySection); + + // Every file + const everyFileSection = el('section'); + const everyFile = model.autoCategories + ? model.everyFile.filter((entry) => matches(needle, entry.label, entry.path)) + : []; + everyFileSection.append( + sectionHead( + texts.everyFile, + model.autoCategories ? model.everyFile.length : undefined, + switchBox(texts.everyFileSwitch, model.autoCategories, 'autoCategories') + ), + el('p', 'hint', texts.everyFileHint) + ); + if (model.autoCategories) { + everyFileSection.append(everyFileRows(everyFile, model.everyFileTruncated)); + } + shown += everyFile.length; + content.append(everyFileSection); + + // Tags + const tags = model.tags.filter((t) => matches(needle, t.name, t.keyword)); + const tagSection = el('section'); + tagSection.append( + sectionHead(texts.tags, model.tags.length, addButton(texts.newTag, { type: 'create', kind: 'tag' })) + ); + if (model.tags.length === 0) { + tagSection.append(el('p', 'empty', texts.noTags)); + } + for (const tag of tags) { + tagSection.append(tagRow(tag)); + } + shown += tags.length; + content.append(tagSection); + + // Tag patterns + const patterns = model.patterns.filter((p) => matches(needle, p.pattern)); + const patternSection = el('section'); + patternSection.append( + sectionHead( + texts.patterns, + model.patterns.length, + switchBox(texts.patternsSwitch, model.autoTagPatterns, 'autoTagPatterns'), + addButton(texts.newPattern, { type: 'pattern', action: 'add' }) + ), + el('p', 'hint', texts.patternsHint) + ); + if (model.patterns.length === 0) { + patternSection.append(el('p', 'empty', texts.noPatterns)); + } + for (const pattern of patterns) { + patternSection.append(patternRow(pattern)); + } + shown += patterns.length; + content.append(patternSection); + + if (needle !== '' && shown === 0) { + content.append(el('p', 'empty', texts.noMatches)); + } + } + + function init(message) { + texts = message.texts; + model = message.model; + page.textContent = ''; + page.append(el('h1', undefined, texts.title), el('p', 'intro', texts.intro)); + + const toolbar = el('div', 'toolbar'); + searchBox = el('input'); + searchBox.type = 'search'; + searchBox.placeholder = texts.searchPlaceholder; + searchBox.setAttribute('aria-label', texts.searchLabel); + searchBox.addEventListener('input', render); + const refresh = el('button', 'secondary', texts.refresh); + refresh.type = 'button'; + refresh.addEventListener('click', () => send({ type: 'refresh' })); + toolbar.append(searchBox, refresh); + content = el('div'); + page.append(toolbar, content); + render(); + } + + window.addEventListener('message', (event) => { + const message = event.data; + if (!message || typeof message !== 'object') { + return; + } + if (message.type === 'init') { + init(message); + } else if (message.type === 'update' && texts) { + model = message.model; + render(); + } + }); + + send({ type: 'ready' }); +})(); diff --git a/media/quickNote.css b/webview/quickNote/quickNote.css similarity index 100% rename from media/quickNote.css rename to webview/quickNote/quickNote.css diff --git a/media/quickNote.js b/webview/quickNote/quickNote.js similarity index 100% rename from media/quickNote.js rename to webview/quickNote/quickNote.js diff --git a/webview/settingsPage/settingsPage.css b/webview/settingsPage/settingsPage.css new file mode 100644 index 0000000..2bf6597 --- /dev/null +++ b/webview/settingsPage/settingsPage.css @@ -0,0 +1,242 @@ +/* Theme variables only, so every light, dark and high contrast theme works. */ + +body { + padding: 16px 24px 40px; + color: var(--vscode-foreground); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); +} + +main { + max-width: 760px; + margin: 0 auto; +} + +h1 { + margin: 0 0 4px; + font-size: 1.5em; + font-weight: 600; +} + +h2 { + margin: 28px 0 8px; + padding-bottom: 4px; + font-size: 1.15em; + font-weight: 600; + border-bottom: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, transparent)); +} + +.intro { + margin: 0 0 16px; + color: var(--vscode-descriptionForeground); +} + +.toolbar { + position: sticky; + top: 0; + z-index: 5; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; + padding: 8px 0; + background: var(--vscode-editor-background); +} + +.toolbar input[type='search'] { + flex: 1 1 220px; + min-width: 0; +} + +.toolbar label { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +input[type='text'], +input[type='search'], +select, +textarea { + box-sizing: border-box; + padding: 4px 8px; + color: var(--vscode-input-foreground); + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, var(--vscode-widget-border, transparent)); + border-radius: 2px; + font-family: inherit; + font-size: inherit; +} + +select { + color: var(--vscode-dropdown-foreground, var(--vscode-input-foreground)); + background: var(--vscode-dropdown-background, var(--vscode-input-background)); + border-color: var(--vscode-dropdown-border, var(--vscode-input-border, transparent)); +} + +textarea { + width: 100%; + /* One entry per line: keep the spaces and do not wrap. */ + font-family: var(--vscode-editor-font-family, monospace); + white-space: pre; + overflow: auto; + resize: vertical; +} + +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.row { + padding: 10px 0 10px 12px; + border-left: 3px solid transparent; +} + +.row[hidden] { + display: none; +} + +/* A setting that was changed from its default has a coloured edge, like the modified settings of VS Code. */ +.row.changed { + border-left-color: var(--vscode-settings-modifiedItemIndicator, var(--vscode-focusBorder)); +} + +.head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; +} + +.name { + font-weight: 600; +} + +.badge { + padding: 0 6px; + color: var(--vscode-badge-foreground); + background: var(--vscode-badge-background); + border-radius: 8px; + font-size: 0.85em; +} + +.badge[hidden] { + display: none; +} + +.desc { + margin: 2px 0 6px; + color: var(--vscode-descriptionForeground); +} + +.control { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.control input[type='text'], +.control select { + flex: 0 1 360px; + min-width: 120px; +} + +.control.list { + align-items: flex-start; +} + +.list-box { + flex: 1 1 100%; +} + +.unit, +.hint, +.value-text { + color: var(--vscode-descriptionForeground); +} + +.meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; + margin-top: 6px; + font-size: 0.92em; + color: var(--vscode-descriptionForeground); +} + +.error { + margin: 6px 0 0; + color: var(--vscode-errorForeground); +} + +.error[hidden], +.saved[hidden] { + display: none; +} + +.saved { + color: var(--vscode-descriptionForeground); + font-size: 0.92em; +} + +button { + padding: 4px 12px; + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); + border: 1px solid var(--vscode-button-border, var(--vscode-contrastBorder, transparent)); + border-radius: 2px; + font-family: inherit; + font-size: inherit; + cursor: pointer; +} + +button:hover:not(:disabled) { + background: var(--vscode-button-hoverBackground); +} + +button:disabled { + opacity: 0.5; + cursor: default; +} + +button.secondary { + color: var(--vscode-button-secondaryForeground); + background: var(--vscode-button-secondaryBackground); +} + +button.secondary:hover:not(:disabled) { + background: var(--vscode-button-secondaryHoverBackground); +} + +button.link { + padding: 0; + color: var(--vscode-textLink-foreground); + background: none; + border: none; + text-decoration: underline; +} + +button.link:hover:not(:disabled) { + color: var(--vscode-textLink-activeForeground); + background: none; +} + +button[hidden] { + display: none; +} + +#none { + margin-top: 24px; + color: var(--vscode-descriptionForeground); +} + +#none[hidden] { + display: none; +} diff --git a/webview/settingsPage/settingsPage.js b/webview/settingsPage/settingsPage.js new file mode 100644 index 0000000..6df2394 --- /dev/null +++ b/webview/settingsPage/settingsPage.js @@ -0,0 +1,287 @@ +// The Settings page. All texts and values come from the extension (see src/webview/settingsMessages.ts); +// nothing is written to the settings from here: every change is a message that the extension checks and saves. +(function () { + 'use strict'; + + const vscode = acquireVsCodeApi(); + const page = document.getElementById('page'); + + /** Texts from the extension, set by `init`. */ + let texts = null; + /** The rows as built: id -> { row, def, ... controls }. */ + const rows = new Map(); + let searchBox = null; + let changedBox = null; + let nothing = null; + + function element(tag, className, text) { + const created = document.createElement(tag); + if (className) { + created.className = className; + } + if (text !== undefined) { + created.textContent = text; + } + return created; + } + + function send(message) { + vscode.postMessage(message); + } + + /** The entries of a list as lines: empty lines are dropped, the spaces of the others are kept. */ + function entriesOf(textarea) { + return textarea.value.split(/\r?\n/).filter((line) => line !== ''); + } + + function build(rowData) { + const item = { data: rowData }; + const row = element('div', 'row'); + row.dataset.id = rowData.id; + item.row = row; + + const head = element('div', 'head'); + const name = element('label', 'name', rowData.label); + item.changedBadge = element('span', 'badge', texts.changed); + item.sourceBadge = element('span', 'badge'); + head.append(name, item.changedBadge, item.sourceBadge); + + const description = element('p', 'desc', rowData.description); + const control = element('div', 'control'); + const controlId = 'control-' + rowData.id; + name.htmlFor = controlId; + + switch (rowData.kind) { + case 'choice': { + const select = element('select'); + select.id = controlId; + for (const choice of rowData.choices || []) { + const option = element('option', undefined, choice.label); + option.value = choice.value; + select.append(option); + } + select.addEventListener('change', () => send({ type: 'edit', id: rowData.id, value: select.value })); + item.input = select; + control.append(select); + break; + } + case 'switch': + case 'toggle': { + const box = element('input'); + box.type = 'checkbox'; + box.id = controlId; + const state = element('span', 'value-text'); + box.addEventListener('change', () => { + state.textContent = box.checked ? texts.on : texts.off; + send({ type: 'edit', id: rowData.id, value: box.checked }); + }); + item.input = box; + item.state = state; + control.append(box, state); + break; + } + case 'text': + case 'number': { + const input = element('input'); + input.type = 'text'; + input.id = controlId; + input.spellcheck = false; + if (rowData.kind === 'number') { + input.inputMode = 'decimal'; + } + // `change` fires when the box is left or Enter is pressed, so a half typed value is never sent. + input.addEventListener('change', () => send({ type: 'edit', id: rowData.id, value: input.value })); + item.input = input; + control.append(input); + if (rowData.unit) { + control.append(element('span', 'unit', rowData.unit)); + } + break; + } + case 'list': { + control.classList.add('list'); + const box = element('div', 'list-box'); + const area = element('textarea'); + area.id = controlId; + area.spellcheck = false; + area.wrap = 'off'; + const apply = element('button', undefined, texts.apply); + apply.disabled = true; + const hint = element('span', 'hint', texts.listHint); + const submit = () => send({ type: 'edit', id: rowData.id, value: entriesOf(area) }); + area.addEventListener('input', () => { + apply.disabled = false; + area.rows = Math.min(Math.max(entriesOf(area).length + 1, 3), 12); + }); + area.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + submit(); + } + }); + apply.addEventListener('click', submit); + item.input = area; + item.apply = apply; + box.append(area); + control.append(box, apply, hint); + break; + } + default: { + // An action: the value is shown, and a button runs the command that changes it. + item.valueText = element('span', 'value-text'); + const change = element('button', 'secondary', texts.change); + change.addEventListener('click', () => send({ type: 'action', id: rowData.id })); + control.append(item.valueText, change); + } + } + + const meta = element('div', 'meta'); + item.defaultText = element('span'); + item.reset = element('button', 'link', texts.reset); + item.reset.addEventListener('click', () => send({ type: 'reset', id: rowData.id })); + item.saved = element('span', 'saved', texts.saved); + item.saved.hidden = true; + meta.append(item.defaultText, item.reset, item.saved); + + item.error = element('p', 'error'); + item.error.setAttribute('role', 'alert'); + item.error.hidden = true; + + row.append(head, description, control, meta, item.error); + return item; + } + + /** Shows the values of `rowData` in its row. A box that has the focus (or a list being edited) is left alone. */ + function refresh(item, rowData) { + item.data = rowData; + item.row.classList.toggle('changed', rowData.changed); + item.changedBadge.hidden = !rowData.changed; + const source = + rowData.source === 'folder' ? texts.sourceFolder : rowData.source === 'user' ? texts.sourceUser : ''; + item.sourceBadge.textContent = source; + item.sourceBadge.hidden = source === ''; + item.defaultText.textContent = texts.defaultLabel + ' ' + rowData.defaultText; + item.reset.hidden = !(rowData.changed && rowData.resettable); + + const input = item.input; + if (!input) { + item.valueText.textContent = rowData.valueText; + return; + } + const editing = document.activeElement === input; + if (rowData.kind === 'switch' || rowData.kind === 'toggle') { + input.checked = rowData.value === true; + item.state.textContent = input.checked ? texts.on : texts.off; + } else if (rowData.kind === 'list') { + if (!editing || item.apply.disabled) { + input.value = rowData.value.join('\n'); + input.rows = Math.min(Math.max(rowData.value.length + 1, 3), 12); + item.apply.disabled = true; + } + } else if (!editing) { + input.value = String(rowData.value); + } + } + + function applyFilter() { + const needle = searchBox.value.trim().toLowerCase(); + const onlyChanged = changedBox.checked; + let shown = 0; + for (const item of rows.values()) { + const matches = + (!onlyChanged || item.data.changed) && + (needle === '' || (item.data.label + ' ' + item.data.description).toLowerCase().includes(needle)); + item.row.hidden = !matches; + shown += matches ? 1 : 0; + } + for (const section of page.querySelectorAll('section')) { + section.hidden = section.querySelectorAll('.row:not([hidden])').length === 0; + } + nothing.hidden = shown !== 0; + } + + function init(message) { + texts = message.texts; + page.textContent = ''; + rows.clear(); + + page.append(element('h1', undefined, texts.title), element('p', 'intro', texts.intro)); + + const toolbar = element('div', 'toolbar'); + searchBox = element('input'); + searchBox.type = 'search'; + searchBox.placeholder = texts.searchPlaceholder; + searchBox.setAttribute('aria-label', texts.searchLabel); + searchBox.addEventListener('input', applyFilter); + const changedLabel = element('label'); + changedBox = element('input'); + changedBox.type = 'checkbox'; + changedBox.addEventListener('change', applyFilter); + changedLabel.append(changedBox, document.createTextNode(texts.onlyChanged)); + toolbar.append(searchBox, changedLabel); + page.append(toolbar); + + const sections = new Map(); + for (const rowData of message.rows) { + let section = sections.get(rowData.group); + if (!section) { + section = element('section'); + section.append(element('h2', undefined, rowData.groupLabel)); + sections.set(rowData.group, section); + page.append(section); + } + const item = build(rowData); + rows.set(rowData.id, item); + section.append(item.row); + refresh(item, rowData); + } + nothing = element('p'); + nothing.id = 'none'; + nothing.textContent = texts.noMatches; + nothing.hidden = true; + page.append(nothing); + applyFilter(); + } + + function update(newRows) { + for (const rowData of newRows) { + const item = rows.get(rowData.id); + if (item) { + refresh(item, rowData); + } + } + applyFilter(); + } + + function showResult(message) { + const item = rows.get(message.id); + if (!item) { + return; + } + item.error.hidden = !message.error; + item.error.textContent = message.error || ''; + item.saved.hidden = !!message.error; + if (!message.error) { + // "Saved" is only a quiet confirmation; it goes away by itself. + setTimeout(() => { + item.saved.hidden = true; + }, 1500); + } + } + + window.addEventListener('message', (event) => { + const message = event.data; + if (!message || typeof message !== 'object') { + return; + } + if (message.type === 'init') { + init(message); + } else if (message.type === 'update' && texts) { + update(message.rows); + } else if (message.type === 'result' && texts) { + showResult(message); + } + }); + + send({ type: 'ready' }); +})();