diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 192c250..c6b1cd2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: Bug report description: Something in Workspace File Bookmarks isn't working as expected -labels: ["bug"] +labels: ['bug'] body: - type: textarea id: description diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index be823fa..b8cfce4 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,6 @@ name: Feature request description: Suggest a feature or improvement for Workspace File Bookmarks -labels: ["enhancement"] +labels: ['enhancement'] body: - type: textarea id: problem diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9ed2bea --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## Summary + + + +## Testing + + + +## Checklist + +- [ ] PR title follows [Conventional Commits](https://www.conventionalcommits.org/) (enforced by `pr-title-lint.yml`) +- [ ] `npm run lint` passes (`tsc --noEmit` + `eslint .`) +- [ ] `npm run format:check` passes +- [ ] `npm run test` passes, and `npm run test:coverage` / `scripts/local-patch-report.sh` shows patch coverage ≥85% +- [ ] `npm run build` passes +- [ ] New/changed user-facing commands or tree behavior have `test/e2e/*.test.ts` coverage in addition to unit tests +- [ ] `npm run test:e2e` passes locally +- [ ] No new high/critical findings from `npm audit --audit-level=high` or CodeQL diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e679fd..8dec338 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,9 @@ jobs: - name: Lint run: npm run lint + - name: Format check + run: npm run format:check + - name: Test with coverage run: npm run test:coverage diff --git a/.github/workflows/renovate_prune.yml b/.github/workflows/renovate_prune.yml index 8148e51..f7b8f63 100644 --- a/.github/workflows/renovate_prune.yml +++ b/.github/workflows/renovate_prune.yml @@ -1,12 +1,12 @@ -name: "Prune Renovate Branches" +name: 'Prune Renovate Branches' on: workflow_dispatch: schedule: - - cron: '0 3 * * *' # daily at 03:00 UTC + - cron: '0 3 * * *' # daily at 03:00 UTC permissions: - contents: write # required to delete branch refs + contents: write # required to delete branch refs pull-requests: read jobs: @@ -17,7 +17,7 @@ jobs: cancel-in-progress: true env: - BRANCH_PREFIX: "renovate/" # adjust if you use a different prefix + BRANCH_PREFIX: 'renovate/' # adjust if you use a different prefix steps: - name: Prune renovate branches diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..0020abf --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +dist/ +coverage/ +out-e2e/ +.vscode-test/ +node_modules/ +*.md +*.vsix +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..29f78a6 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "trailingComma": "all" +} diff --git a/.vscode-test.mjs b/.vscode-test.mjs index e615cb8..9a479b1 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,10 +1,10 @@ import { defineConfig } from '@vscode/test-cli'; export default defineConfig({ - files: 'out-e2e/**/*.test.js', - workspaceFolder: 'test/e2e/fixtures/workspace', - mocha: { - ui: 'bdd', - timeout: 20000 - } + files: 'out-e2e/**/*.test.js', + workspaceFolder: 'test/e2e/fixtures/workspace', + mocha: { + ui: 'bdd', + timeout: 20000, + }, }); diff --git a/CLAUDE.md b/CLAUDE.md index dacdff3..a6d37fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,9 +20,13 @@ drives versioning and publishing off Conventional Commit prefixes on `main`. Every change is expected to clear this bar before it's mergeable: -- **CI clean.** `npm run lint` (`tsc --noEmit`), `npm run test`, - `npm run build`, and `npm run test:e2e` all pass — see - `.github/workflows/ci.yml`. +- **CI clean.** `npm run lint` (`npm run lint:types` — `tsc --noEmit` — + followed by `npm run lint:style` — `eslint .`), `npm run format:check`, + `npm run test`, `npm run build`, and `npm run test:e2e` all pass — see + `.github/workflows/ci.yml`. `lefthook` runs the same lint/format checks and + the unit suite locally on `pre-commit` against staged files (installed + automatically via the `postinstall` script after `npm install`; see + `lefthook.yml`), so most of this is caught before it ever reaches CI. - **Unit coverage ≥85%, both patch and project.** Codecov enforces this on every PR (`codecov.yml`: `project` and `patch`, both `target: 85%, threshold: 1%`), reported from `npm run test:coverage` (vitest + v8, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3dad409 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,80 @@ +# Contributing + +Thanks for considering a contribution to Workspace File Bookmarks. This +document covers the workflow, tooling, and quality bar a PR is expected to +meet. + +## Branching model + +- Branch off `development`, not `main` — `main` only moves via + `development` → `main` promotion or direct CI/hotfix commits. +- Feature work happens on `feature/**` branches, one PR per feature. A PR + may be split into multiple commits, but don't merge a feature that isn't + complete. If your change spans multiple unrelated features, split it into + multiple PRs rather than combining them. +- `development` batches dependency and small maintenance updates so a merge + into `main` doesn't trigger a release per commit. Once `development`'s CI + is green, a weekly `promote-dev-to-main` PR opens the other direction. +- release-please drives versioning and changelog generation off Conventional + Commit prefixes on `main` — see [Commit messages](#commit-messages) below. + +## Getting started + +```bash +npm install # also wires up lefthook's pre-commit checks +npm run build # bundle extension.ts -> dist/extension.cjs +npm run watch # rebuild on change +``` + +Press `F5` in VS Code to launch an Extension Development Host with the +extension loaded, so you can exercise your change interactively. + +## Commit messages / PR titles + +PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/) +(`feat:`, `fix:`, `chore:`, `docs:`, etc.) — this is enforced by +`.github/workflows/pr-title-lint.yml` and is what release-please uses to +decide the next version and changelog entry on `main`. + +## Definition of Done + +Every change is expected to clear this bar before it's mergeable: + +- **CI clean.** `npm run lint` (type-check via `tsc --noEmit`, then + `eslint .`), `npm run format:check`, `npm run test`, `npm run build`, and + `npm run test:e2e` all pass — see `.github/workflows/ci.yml`. +- **Unit coverage ≥85%, both patch and project.** Codecov enforces this on + every PR (`codecov.yml`), reported from `npm run test:coverage` (vitest + + v8; `vitest.config.mts` mirrors the same 85% thresholds so a local run + fails the same way CI's will). Before pushing, run + `scripts/local-patch-report.sh` — it diffs your branch against + `origin/development`, regenerates a fresh coverage profile, and reports + the same changed-lines coverage number Codecov's patch gate computes, so a + real gap shows up before a CI round-trip instead of after. Write real + tests that close gaps for real — no padding, no vacuous assertions just to + move a number. +- **New features carry e2e coverage, not just unit tests.** Unit tests + (vitest, against `test/vscode-mock.ts`) cover logic in isolation; + `test/e2e/*.test.ts` (mocha, via `@vscode/test-cli` + `@vscode/test-electron`) + runs the real, built extension inside an actual VS Code Extension + Development Host. Any new user-facing command or tree behavior needs both: + a unit test for the logic and an e2e test exercising it end-to-end. Run + locally with `npm run test:e2e` (needs a display, or `xvfb-run -a npm run + test:e2e` on headless Linux). +- **Security scanning clean.** CodeQL runs on every push/PR + (`.github/workflows/codeql.yml`); `npm audit --audit-level=high` gates + dependency updates on high/critical findings. + +## Local pre-commit checks + +`npm install` runs [lefthook](https://github.com/evilmartians/lefthook)'s +`postinstall` hook, which installs a `pre-commit` hook (see `lefthook.yml`) +that runs lint, format, and the unit suite against your staged files. This +catches most Definition of Done violations before they reach CI — if it's +slow or wrong, please open an issue rather than routinely bypassing it with +`--no-verify`. + +## Opening a PR + +Fill out the PR template's checklist — it mirrors the Definition of Done +above. Link the issue it closes, if any. diff --git a/README.md b/README.md index 4da3502..7810164 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,16 @@ This extension is early — I'm actively looking for feedback on what's useful a npm install npm run build # bundle extension.ts -> dist/extension.cjs npm run watch # rebuild on change -npm run lint # type-check only +npm run lint # type-check (tsc) + eslint +npm run format # prettier --write ``` Press `F5` in VS Code to launch an Extension Development Host with the extension loaded. +`npm install` also wires up [lefthook](https://github.com/evilmartians/lefthook) to +run lint/format/test checks on `pre-commit`. See [CONTRIBUTING.md](CONTRIBUTING.md) +for the full workflow and Definition of Done. + ## Packaging / local install ```bash @@ -51,4 +56,4 @@ npm run package # builds, packages a .vsix, and installs it into VS Code / In ## Roadmap -Renaming/tagging bookmarks, reordering, and drag-and-drop. +Renaming/tagging bookmarks, reordering, drag-and-drop, a quick-filter search box, and nested (sub-)folders are all shipped. diff --git a/codecov.yml b/codecov.yml index 9312bbc..fb858f7 100644 --- a/codecov.yml +++ b/codecov.yml @@ -22,17 +22,17 @@ comment: require_changes: false require_base: false require_head: true - layout: "reach, diff, flags, files" + layout: 'reach, diff, flags, files' behavior: default # Keep in sync with scripts/local-patch-report.sh's own exclude list. ignore: - - "test/**" - - "**/*.test.ts" - - "**/*.d.ts" - - "scripts/**" - - "*.md" - - ".github/**" - - "vitest.config.mts" - - "dist/**" - - "coverage/**" + - 'test/**' + - '**/*.test.ts' + - '**/*.d.ts' + - 'scripts/**' + - '*.md' + - '.github/**' + - 'vitest.config.mts' + - 'dist/**' + - 'coverage/**' diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..3fbaf83 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,19 @@ +// @ts-check +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import eslintConfigPrettier from 'eslint-config-prettier'; + +export default tseslint.config( + { + ignores: ['dist/**', 'coverage/**', 'out-e2e/**', '.vscode-test/**', 'node_modules/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + rules: { + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + eslintConfigPrettier, +); diff --git a/extension.ts b/extension.ts index 85e0d92..b7ef1f3 100644 --- a/extension.ts +++ b/extension.ts @@ -10,515 +10,791 @@ const SEARCH_FILTER_CONTEXT_KEY = 'workspace-file-bookmarks.searchFilterActive'; type ViewMode = 'tree' | 'list'; export interface Bookmark { - id: string; - uri: string; - label: string; - relativePath: string; - workspaceFolderName: string; - folderId: string | null; - createdAt: number; - order?: number; - tags?: string[]; + id: string; + uri: string; + label: string; + relativePath: string; + workspaceFolderName: string; + folderId: string | null; + createdAt: number; + order?: number; + tags?: string[]; } export interface BookmarkFolder { - id: string; - name: string; - createdAt: number; - order?: number; + id: string; + name: string; + createdAt: number; + order?: number; + parentId?: string | null; } +/** Folders may nest up to this many levels deep (root-level folders are depth 1). */ +export const MAX_FOLDER_DEPTH = 3; + export const BOOKMARK_DND_MIME_TYPE = 'application/vnd.code.tree.workspace-file-bookmarks-view'; /** Sorts by explicit `order` when set (ascending), falling back for items without one. Items with an order always sort before items without. */ -export function sortByOrder(items: T[], fallbackCompare: (a: T, b: T) => number): T[] { - return items.slice().sort((a, b) => { - if (a.order !== undefined && b.order !== undefined) { - return a.order - b.order; - } - if (a.order !== undefined) { - return -1; - } - if (b.order !== undefined) { - return 1; - } - return fallbackCompare(a, b); - }); +export function sortByOrder( + items: T[], + fallbackCompare: (a: T, b: T) => number, +): T[] { + return items.slice().sort((a, b) => { + if (a.order !== undefined && b.order !== undefined) { + return a.order - b.order; + } + if (a.order !== undefined) { + return -1; + } + if (b.order !== undefined) { + return 1; + } + return fallbackCompare(a, b); + }); } /** Computes the ordered id list for a drag-and-drop scope: `draggedId` removed then reinserted before `beforeId` (or appended when `beforeId` is null). */ export function computeReorderedIds( - scopeItems: T[], - draggedId: string, - beforeId: string | null, - fallbackCompare: (a: T, b: T) => number + scopeItems: T[], + draggedId: string, + beforeId: string | null, + fallbackCompare: (a: T, b: T) => number, ): string[] { - const ids = sortByOrder(scopeItems, fallbackCompare) - .filter(item => item.id !== draggedId) - .map(item => item.id); - const insertAt = beforeId ? ids.indexOf(beforeId) : -1; - if (insertAt === -1) { - ids.push(draggedId); - } else { - ids.splice(insertAt, 0, draggedId); - } - return ids; + const ids = sortByOrder(scopeItems, fallbackCompare) + .filter((item) => item.id !== draggedId) + .map((item) => item.id); + const insertAt = beforeId ? ids.indexOf(beforeId) : -1; + if (insertAt === -1) { + ids.push(draggedId); + } else { + ids.splice(insertAt, 0, draggedId); + } + return ids; } const bookmarkFallbackCompare = (a: Bookmark, b: Bookmark) => b.createdAt - a.createdAt; -const folderFallbackCompare = (a: BookmarkFolder, b: BookmarkFolder) => a.name.localeCompare(b.name); +const folderFallbackCompare = (a: BookmarkFolder, b: BookmarkFolder) => + a.name.localeCompare(b.name); -/** The extension's public API, returned from `activate()` — used by e2e tests to inspect state that isn't reachable through the tree view UI alone. */ -export interface ExtensionApi { - store: BookmarkStore; - provider: BookmarksTreeProvider; +function folderParentId(folder: BookmarkFolder): string | null { + return folder.parentId ?? null; } -export function activate(context: vscode.ExtensionContext): ExtensionApi { - const store = new BookmarkStore(context); - const provider = new BookmarksTreeProvider(store, context); - - const treeView = vscode.window.createTreeView('workspace-file-bookmarks-view', { - treeDataProvider: provider, - showCollapseAll: true, - dragAndDropController: provider - }); - - vscode.commands.executeCommand('setContext', VIEW_MODE_CONTEXT_KEY, provider.getViewMode()); - vscode.commands.executeCommand('setContext', TAG_FILTER_CONTEXT_KEY, provider.getTagFilter() !== null); - vscode.commands.executeCommand('setContext', SEARCH_FILTER_CONTEXT_KEY, provider.getSearchFilter() !== null); - - context.subscriptions.push( - treeView, - vscode.commands.registerCommand('workspace-file-bookmarks.addBookmark', () => addActiveFileBookmark(store)), - vscode.commands.registerCommand('workspace-file-bookmarks.addBookmarkFromExplorer', (uri: vscode.Uri | undefined, uris: vscode.Uri[] | undefined) => addBookmarksForUris(store, uri, uris)), - vscode.commands.registerCommand('workspace-file-bookmarks.addBookmarkToFolder', (uri: vscode.Uri | undefined, uris: vscode.Uri[] | undefined) => addBookmarksToFolder(store, uri, uris)), - vscode.commands.registerCommand('workspace-file-bookmarks.removeBookmark', (item: BookmarkTreeItem) => store.removeBookmark(item.bookmark.id)), - vscode.commands.registerCommand('workspace-file-bookmarks.renameBookmark', (item: BookmarkTreeItem) => renameBookmark(store, item)), - vscode.commands.registerCommand('workspace-file-bookmarks.editTags', (item: BookmarkTreeItem) => editBookmarkTags(store, item)), - vscode.commands.registerCommand('workspace-file-bookmarks.filterByTag', () => filterByTag(store, provider)), - vscode.commands.registerCommand('workspace-file-bookmarks.clearTagFilter', () => provider.setTagFilter(null)), - vscode.commands.registerCommand('workspace-file-bookmarks.filterBookmarks', () => filterBookmarks(provider)), - vscode.commands.registerCommand('workspace-file-bookmarks.clearSearchFilter', () => provider.setSearchFilter(null)), - vscode.commands.registerCommand('workspace-file-bookmarks.openBookmark', (bookmark: Bookmark) => openBookmark(bookmark)), - vscode.commands.registerCommand('workspace-file-bookmarks.createFolder', () => createFolder(store)), - vscode.commands.registerCommand('workspace-file-bookmarks.renameFolder', (item: FolderGroupItem) => renameFolder(store, item)), - vscode.commands.registerCommand('workspace-file-bookmarks.deleteFolder', (item: FolderGroupItem) => deleteFolder(store, item)), - vscode.commands.registerCommand('workspace-file-bookmarks.moveToFolder', (item: BookmarkTreeItem) => moveToFolder(store, item)), - vscode.commands.registerCommand('workspace-file-bookmarks.openAllInFolder', (item: FolderGroupItem) => openAllInFolder(store, item)), - vscode.commands.registerCommand('workspace-file-bookmarks.setViewModeList', () => provider.setViewMode('list')), - vscode.commands.registerCommand('workspace-file-bookmarks.setViewModeTree', () => provider.setViewMode('tree')) - ); - - return { store, provider }; +/** Number of ancestor folders above `parentId` (0 if `parentId` is null/root). */ +function ancestorDepth(folders: BookmarkFolder[], parentId: string | null): number { + const byId = new Map(folders.map((f) => [f.id, f])); + let depth = 0; + let current = parentId; + const seen = new Set(); + while (current && !seen.has(current)) { + seen.add(current); + depth++; + current = byId.get(current)?.parentId ?? null; + } + return depth; } -export function deactivate() {} - -export class BookmarkStore { - private readonly _onDidChange = new vscode.EventEmitter(); - readonly onDidChange = this._onDidChange.event; - - constructor(private readonly context: vscode.ExtensionContext) {} +/** Height of the subtree rooted at `folderId` (0 for a folder with no child folders). */ +export function folderSubtreeHeight(folders: BookmarkFolder[], folderId: string): number { + const children = folders.filter((f) => folderParentId(f) === folderId); + if (children.length === 0) { + return 0; + } + return 1 + Math.max(...children.map((c) => folderSubtreeHeight(folders, c.id))); +} - getAllBookmarks(): Bookmark[] { - return this.context.workspaceState.get(STORAGE_KEY_BOOKMARKS, []); +/** Whether `candidateId` is `ancestorId` itself or one of its descendants. */ +export function isDescendantFolder( + folders: BookmarkFolder[], + ancestorId: string, + candidateId: string, +): boolean { + const byId = new Map(folders.map((f) => [f.id, f])); + let current: string | null = candidateId; + const seen = new Set(); + while (current && !seen.has(current)) { + if (current === ancestorId) { + return true; } + seen.add(current); + current = byId.get(current)?.parentId ?? null; + } + return false; +} - getAllFolders(): BookmarkFolder[] { - return this.context.workspaceState.get(STORAGE_KEY_FOLDERS, []); - } +/** Whether nesting a folder (or a subtree of the given height) under `parentId` stays within MAX_FOLDER_DEPTH. */ +export function canNestUnder( + folders: BookmarkFolder[], + parentId: string | null, + subtreeHeight = 0, +): boolean { + return ancestorDepth(folders, parentId) + 1 + subtreeHeight <= MAX_FOLDER_DEPTH; +} - addBookmark(bookmark: Bookmark) { - const existing = this.getAllBookmarks(); - if (existing.some(b => b.uri === bookmark.uri)) { - vscode.window.showInformationMessage(`Already bookmarked: ${bookmark.relativePath}`); - return; - } - this.setBookmarks([bookmark, ...existing]); +/** Folders eligible to be a parent: excludes a folder (and its own descendants, via `excludeFolderId`) and any folder where nesting would exceed MAX_FOLDER_DEPTH. */ +export function eligibleParentFolders( + store: BookmarkStore, + options: { excludeFolderId?: string } = {}, +): BookmarkFolder[] { + const folders = store.getAllFolders(); + const subtreeHeight = options.excludeFolderId + ? folderSubtreeHeight(folders, options.excludeFolderId) + : 0; + return folders.filter((f) => { + if ( + options.excludeFolderId && + (f.id === options.excludeFolderId || + isDescendantFolder(folders, options.excludeFolderId, f.id)) + ) { + return false; } + return canNestUnder(folders, f.id, subtreeHeight); + }); +} - removeBookmark(id: string) { - this.setBookmarks(this.getAllBookmarks().filter(b => b.id !== id)); +/** Breadcrumb label for a folder, e.g. "Backend › Auth Service". A root-level folder's breadcrumb is just its name. */ +export function folderBreadcrumb(folders: BookmarkFolder[], folderId: string): string { + const byId = new Map(folders.map((f) => [f.id, f])); + const parts: string[] = []; + let current: string | null = folderId; + const seen = new Set(); + while (current && !seen.has(current)) { + const folder = byId.get(current); + if (!folder) { + break; } + parts.unshift(folder.name); + seen.add(current); + current = folderParentId(folder); + } + return parts.join(' › '); +} - moveBookmarkToFolder(id: string, folderId: string | null) { - this.setBookmarks(this.getAllBookmarks().map(b => (b.id === id ? { ...b, folderId } : b))); - } +/** The extension's public API, returned from `activate()` — used by e2e tests to inspect state that isn't reachable through the tree view UI alone. */ +export interface ExtensionApi { + store: BookmarkStore; + provider: BookmarksTreeProvider; +} - renameBookmark(id: string, label: string) { - this.setBookmarks(this.getAllBookmarks().map(b => (b.id === id ? { ...b, label } : b))); - } +export function activate(context: vscode.ExtensionContext): ExtensionApi { + const store = new BookmarkStore(context); + const provider = new BookmarksTreeProvider(store, context); + + const treeView = vscode.window.createTreeView('workspace-file-bookmarks-view', { + treeDataProvider: provider, + showCollapseAll: true, + dragAndDropController: provider, + }); + + vscode.commands.executeCommand('setContext', VIEW_MODE_CONTEXT_KEY, provider.getViewMode()); + vscode.commands.executeCommand( + 'setContext', + TAG_FILTER_CONTEXT_KEY, + provider.getTagFilter() !== null, + ); + vscode.commands.executeCommand( + 'setContext', + SEARCH_FILTER_CONTEXT_KEY, + provider.getSearchFilter() !== null, + ); + + context.subscriptions.push( + treeView, + vscode.commands.registerCommand('workspace-file-bookmarks.addBookmark', () => + addActiveFileBookmark(store), + ), + vscode.commands.registerCommand( + 'workspace-file-bookmarks.addBookmarkFromExplorer', + (uri: vscode.Uri | undefined, uris: vscode.Uri[] | undefined) => + addBookmarksForUris(store, uri, uris), + ), + vscode.commands.registerCommand( + 'workspace-file-bookmarks.addBookmarkToFolder', + (uri: vscode.Uri | undefined, uris: vscode.Uri[] | undefined) => + addBookmarksToFolder(store, uri, uris), + ), + vscode.commands.registerCommand( + 'workspace-file-bookmarks.removeBookmark', + (item: BookmarkTreeItem) => store.removeBookmark(item.bookmark.id), + ), + vscode.commands.registerCommand( + 'workspace-file-bookmarks.renameBookmark', + (item: BookmarkTreeItem) => renameBookmark(store, item), + ), + vscode.commands.registerCommand('workspace-file-bookmarks.editTags', (item: BookmarkTreeItem) => + editBookmarkTags(store, item), + ), + vscode.commands.registerCommand('workspace-file-bookmarks.filterByTag', () => + filterByTag(store, provider), + ), + vscode.commands.registerCommand('workspace-file-bookmarks.clearTagFilter', () => + provider.setTagFilter(null), + ), + vscode.commands.registerCommand('workspace-file-bookmarks.filterBookmarks', () => + filterBookmarks(provider), + ), + vscode.commands.registerCommand('workspace-file-bookmarks.clearSearchFilter', () => + provider.setSearchFilter(null), + ), + vscode.commands.registerCommand('workspace-file-bookmarks.openBookmark', (bookmark: Bookmark) => + openBookmark(bookmark), + ), + vscode.commands.registerCommand('workspace-file-bookmarks.createFolder', () => + createFolder(store), + ), + vscode.commands.registerCommand( + 'workspace-file-bookmarks.createSubfolder', + (item: FolderGroupItem) => createSubfolder(store, item), + ), + vscode.commands.registerCommand( + 'workspace-file-bookmarks.moveFolderToParent', + (item: FolderGroupItem) => moveFolderToParent(store, item), + ), + vscode.commands.registerCommand( + 'workspace-file-bookmarks.renameFolder', + (item: FolderGroupItem) => renameFolder(store, item), + ), + vscode.commands.registerCommand( + 'workspace-file-bookmarks.deleteFolder', + (item: FolderGroupItem) => deleteFolder(store, item), + ), + vscode.commands.registerCommand( + 'workspace-file-bookmarks.moveToFolder', + (item: BookmarkTreeItem) => moveToFolder(store, item), + ), + vscode.commands.registerCommand( + 'workspace-file-bookmarks.openAllInFolder', + (item: FolderGroupItem) => openAllInFolder(store, item), + ), + vscode.commands.registerCommand('workspace-file-bookmarks.setViewModeList', () => + provider.setViewMode('list'), + ), + vscode.commands.registerCommand('workspace-file-bookmarks.setViewModeTree', () => + provider.setViewMode('tree'), + ), + ); + + return { store, provider }; +} - setBookmarkTags(id: string, tags: string[]) { - this.setBookmarks(this.getAllBookmarks().map(b => (b.id === id ? { ...b, tags } : b))); - } +export function deactivate() {} - /** Every distinct tag across all bookmarks, sorted alphabetically. */ - getAllTags(): string[] { - const tags = new Set(); - for (const bookmark of this.getAllBookmarks()) { - for (const tag of bookmark.tags ?? []) { - tags.add(tag); - } - } - return [...tags].sort((a, b) => a.localeCompare(b)); - } +export class BookmarkStore { + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChange = this._onDidChange.event; - /** Assigns sequential `order` values to the bookmarks named in `orderedIds`; bookmarks outside that set are untouched. */ - reorderBookmarks(orderedIds: string[]) { - const orderById = new Map(orderedIds.map((id, index) => [id, index])); - this.setBookmarks(this.getAllBookmarks().map(b => (orderById.has(b.id) ? { ...b, order: orderById.get(b.id) } : b))); - } + constructor(private readonly context: vscode.ExtensionContext) {} - /** Assigns sequential `order` values to the folders named in `orderedIds`; folders outside that set are untouched. */ - reorderFolders(orderedIds: string[]) { - const orderById = new Map(orderedIds.map((id, index) => [id, index])); - this.setFolders(this.getAllFolders().map(f => (orderById.has(f.id) ? { ...f, order: orderById.get(f.id) } : f))); - } + getAllBookmarks(): Bookmark[] { + return this.context.workspaceState.get(STORAGE_KEY_BOOKMARKS, []); + } - createFolder(name: string): BookmarkFolder { - const folder: BookmarkFolder = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - name, - createdAt: Date.now() - }; - this.setFolders([...this.getAllFolders(), folder]); - return folder; - } + getAllFolders(): BookmarkFolder[] { + return this.context.workspaceState.get(STORAGE_KEY_FOLDERS, []); + } - renameFolder(id: string, name: string) { - this.setFolders(this.getAllFolders().map(f => (f.id === id ? { ...f, name } : f))); + addBookmark(bookmark: Bookmark) { + const existing = this.getAllBookmarks(); + if (existing.some((b) => b.uri === bookmark.uri)) { + vscode.window.showInformationMessage(`Already bookmarked: ${bookmark.relativePath}`); + return; } - - deleteFolder(id: string) { - this.setFolders(this.getAllFolders().filter(f => f.id !== id)); - this.setBookmarks(this.getAllBookmarks().map(b => (b.folderId === id ? { ...b, folderId: null } : b))); + this.setBookmarks([bookmark, ...existing]); + } + + removeBookmark(id: string) { + this.setBookmarks(this.getAllBookmarks().filter((b) => b.id !== id)); + } + + moveBookmarkToFolder(id: string, folderId: string | null) { + this.setBookmarks(this.getAllBookmarks().map((b) => (b.id === id ? { ...b, folderId } : b))); + } + + renameBookmark(id: string, label: string) { + this.setBookmarks(this.getAllBookmarks().map((b) => (b.id === id ? { ...b, label } : b))); + } + + setBookmarkTags(id: string, tags: string[]) { + this.setBookmarks(this.getAllBookmarks().map((b) => (b.id === id ? { ...b, tags } : b))); + } + + /** Every distinct tag across all bookmarks, sorted alphabetically. */ + getAllTags(): string[] { + const tags = new Set(); + for (const bookmark of this.getAllBookmarks()) { + for (const tag of bookmark.tags ?? []) { + tags.add(tag); + } } + return [...tags].sort((a, b) => a.localeCompare(b)); + } + + /** Assigns sequential `order` values to the bookmarks named in `orderedIds`; bookmarks outside that set are untouched. */ + reorderBookmarks(orderedIds: string[]) { + const orderById = new Map(orderedIds.map((id, index) => [id, index])); + this.setBookmarks( + this.getAllBookmarks().map((b) => + orderById.has(b.id) ? { ...b, order: orderById.get(b.id) } : b, + ), + ); + } + + /** Assigns sequential `order` values to the folders named in `orderedIds`; folders outside that set are untouched. */ + reorderFolders(orderedIds: string[]) { + const orderById = new Map(orderedIds.map((id, index) => [id, index])); + this.setFolders( + this.getAllFolders().map((f) => + orderById.has(f.id) ? { ...f, order: orderById.get(f.id) } : f, + ), + ); + } + + createFolder(name: string, parentId: string | null = null): BookmarkFolder { + const folder: BookmarkFolder = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name, + createdAt: Date.now(), + parentId, + }; + this.setFolders([...this.getAllFolders(), folder]); + return folder; + } + + renameFolder(id: string, name: string) { + this.setFolders(this.getAllFolders().map((f) => (f.id === id ? { ...f, name } : f))); + } + + moveFolderToParent(id: string, parentId: string | null) { + this.setFolders(this.getAllFolders().map((f) => (f.id === id ? { ...f, parentId } : f))); + } + + /** Deletes a folder, promoting its direct bookmarks and child folders to the root rather than deleting them. */ + deleteFolder(id: string) { + this.setFolders( + this.getAllFolders() + .filter((f) => f.id !== id) + .map((f) => (f.parentId === id ? { ...f, parentId: null } : f)), + ); + this.setBookmarks( + this.getAllBookmarks().map((b) => (b.folderId === id ? { ...b, folderId: null } : b)), + ); + } - private setBookmarks(bookmarks: Bookmark[]) { - this.context.workspaceState.update(STORAGE_KEY_BOOKMARKS, bookmarks); - this._onDidChange.fire(); - } + private setBookmarks(bookmarks: Bookmark[]) { + this.context.workspaceState.update(STORAGE_KEY_BOOKMARKS, bookmarks); + this._onDidChange.fire(); + } - private setFolders(folders: BookmarkFolder[]) { - this.context.workspaceState.update(STORAGE_KEY_FOLDERS, folders); - this._onDidChange.fire(); - } + private setFolders(folders: BookmarkFolder[]) { + this.context.workspaceState.update(STORAGE_KEY_FOLDERS, folders); + this._onDidChange.fire(); + } } export class FolderGroupItem extends vscode.TreeItem { - constructor(public readonly folder: BookmarkFolder, public readonly bookmarks: Bookmark[]) { - super(folder.name, vscode.TreeItemCollapsibleState.Expanded); - this.contextValue = 'bookmarkFolder'; - this.iconPath = new vscode.ThemeIcon('folder'); - this.description = `${bookmarks.length}`; - } + constructor( + public readonly folder: BookmarkFolder, + public readonly bookmarks: Bookmark[], + public readonly childFolders: BookmarkFolder[] = [], + ) { + super(folder.name, vscode.TreeItemCollapsibleState.Expanded); + this.contextValue = 'bookmarkFolder'; + this.iconPath = new vscode.ThemeIcon('folder'); + this.description = + childFolders.length > 0 + ? `${bookmarks.length} • ${childFolders.length} subfolder${childFolders.length === 1 ? '' : 's'}` + : `${bookmarks.length}`; + } } export class BookmarkTreeItem extends vscode.TreeItem { - constructor(public readonly bookmark: Bookmark, descriptionParts: string[]) { - super(bookmark.label, vscode.TreeItemCollapsibleState.None); - this.description = descriptionParts.join(' • '); - this.tooltip = bookmark.relativePath; - this.iconPath = new vscode.ThemeIcon('bookmark'); - this.contextValue = 'bookmarkItem'; - this.command = { - command: 'workspace-file-bookmarks.openBookmark', - title: 'Open Bookmark', - arguments: [bookmark] - }; - } + constructor( + public readonly bookmark: Bookmark, + descriptionParts: string[], + ) { + super(bookmark.label, vscode.TreeItemCollapsibleState.None); + this.description = descriptionParts.join(' • '); + this.tooltip = bookmark.relativePath; + this.iconPath = new vscode.ThemeIcon('bookmark'); + this.contextValue = 'bookmarkItem'; + this.command = { + command: 'workspace-file-bookmarks.openBookmark', + title: 'Open Bookmark', + arguments: [bookmark], + }; + } } type TreeNode = FolderGroupItem | BookmarkTreeItem; -export class BookmarksTreeProvider implements vscode.TreeDataProvider, vscode.TreeDragAndDropController { - private readonly _onDidChangeTreeData = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - - private viewMode: ViewMode; - private tagFilter: string | null = null; - private searchFilter: string | null = null; - - constructor(private readonly store: BookmarkStore, private readonly context: vscode.ExtensionContext) { - this.viewMode = this.context.workspaceState.get(STORAGE_KEY_VIEW_MODE, 'tree'); - store.onDidChange(() => this._onDidChangeTreeData.fire()); +export class BookmarksTreeProvider + implements vscode.TreeDataProvider, vscode.TreeDragAndDropController +{ + private readonly _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private viewMode: ViewMode; + private tagFilter: string | null = null; + private searchFilter: string | null = null; + + constructor( + private readonly store: BookmarkStore, + private readonly context: vscode.ExtensionContext, + ) { + this.viewMode = this.context.workspaceState.get(STORAGE_KEY_VIEW_MODE, 'tree'); + store.onDidChange(() => this._onDidChangeTreeData.fire()); + } + + getViewMode(): ViewMode { + return this.viewMode; + } + + setViewMode(mode: ViewMode) { + this.viewMode = mode; + this.context.workspaceState.update(STORAGE_KEY_VIEW_MODE, mode); + vscode.commands.executeCommand('setContext', VIEW_MODE_CONTEXT_KEY, mode); + this._onDidChangeTreeData.fire(); + } + + getTagFilter(): string | null { + return this.tagFilter; + } + + setTagFilter(tag: string | null) { + this.tagFilter = tag; + vscode.commands.executeCommand('setContext', TAG_FILTER_CONTEXT_KEY, tag !== null); + this._onDidChangeTreeData.fire(); + } + + getSearchFilter(): string | null { + return this.searchFilter; + } + + setSearchFilter(text: string | null) { + this.searchFilter = text && text.trim() ? text : null; + vscode.commands.executeCommand( + 'setContext', + SEARCH_FILTER_CONTEXT_KEY, + this.searchFilter !== null, + ); + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: TreeNode): vscode.TreeItem { + return element; + } + + getChildren(element?: TreeNode): TreeNode[] { + const isMultiRoot = (vscode.workspace.workspaceFolders?.length ?? 0) > 1; + const folders = this.store.getAllFolders(); + const tagFilter = this.tagFilter; + const searchFilter = this.searchFilter; + let bookmarks = this.store.getAllBookmarks(); + if (tagFilter) { + bookmarks = bookmarks.filter((b) => (b.tags ?? []).includes(tagFilter)); } - - getViewMode(): ViewMode { - return this.viewMode; + if (searchFilter) { + bookmarks = bookmarks.filter((b) => matchesSearchFilter(b, searchFilter)); } - setViewMode(mode: ViewMode) { - this.viewMode = mode; - this.context.workspaceState.update(STORAGE_KEY_VIEW_MODE, mode); - vscode.commands.executeCommand('setContext', VIEW_MODE_CONTEXT_KEY, mode); - this._onDidChangeTreeData.fire(); + if (this.viewMode === 'list') { + if (element) { + return []; + } + return sortByOrder(bookmarks, bookmarkFallbackCompare).map( + (b) => + new BookmarkTreeItem( + b, + describeBookmark(b, isMultiRoot, folderBreadcrumb(folders, b.folderId ?? '') || null), + ), + ); } - getTagFilter(): string | null { - return this.tagFilter; + const buildFolderNode = (folder: BookmarkFolder): FolderGroupItem => + new FolderGroupItem( + folder, + bookmarks.filter((b) => b.folderId === folder.id), + folders.filter((f) => (f.parentId ?? null) === folder.id), + ); + + if (element instanceof FolderGroupItem) { + const childFolderNodes = sortByOrder(element.childFolders, folderFallbackCompare).map( + buildFolderNode, + ); + const childBookmarkNodes = sortByOrder(element.bookmarks, bookmarkFallbackCompare).map( + (b) => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, null)), + ); + return [...childFolderNodes, ...childBookmarkNodes]; } - setTagFilter(tag: string | null) { - this.tagFilter = tag; - vscode.commands.executeCommand('setContext', TAG_FILTER_CONTEXT_KEY, tag !== null); - this._onDidChangeTreeData.fire(); + if (element) { + return []; } - getSearchFilter(): string | null { - return this.searchFilter; - } + const folderNodes = sortByOrder( + folders.filter((f) => (f.parentId ?? null) === null), + folderFallbackCompare, + ).map(buildFolderNode); - setSearchFilter(text: string | null) { - this.searchFilter = text && text.trim() ? text : null; - vscode.commands.executeCommand('setContext', SEARCH_FILTER_CONTEXT_KEY, this.searchFilter !== null); - this._onDidChangeTreeData.fire(); - } + const ungrouped = sortByOrder( + bookmarks.filter((b) => !b.folderId), + bookmarkFallbackCompare, + ).map((b) => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, null))); - getTreeItem(element: TreeNode): vscode.TreeItem { - return element; - } + return [...folderNodes, ...ungrouped]; + } - getChildren(element?: TreeNode): TreeNode[] { - const isMultiRoot = (vscode.workspace.workspaceFolders?.length ?? 0) > 1; - const folders = this.store.getAllFolders(); - const tagFilter = this.tagFilter; - const searchFilter = this.searchFilter; - let bookmarks = this.store.getAllBookmarks(); - if (tagFilter) { - bookmarks = bookmarks.filter(b => (b.tags ?? []).includes(tagFilter)); - } - if (searchFilter) { - bookmarks = bookmarks.filter(b => matchesSearchFilter(b, searchFilter)); - } - - if (this.viewMode === 'list') { - if (element) { - return []; - } - const folderById = new Map(folders.map(f => [f.id, f.name])); - return sortByOrder(bookmarks, bookmarkFallbackCompare).map( - b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, folderById.get(b.folderId ?? '') ?? null)) - ); - } - - if (element instanceof FolderGroupItem) { - return sortByOrder(element.bookmarks, bookmarkFallbackCompare).map( - b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, null)) - ); - } - - if (element) { - return []; - } - - const folderNodes = sortByOrder(folders, folderFallbackCompare).map( - folder => new FolderGroupItem(folder, bookmarks.filter(b => b.folderId === folder.id)) - ); - - const ungrouped = sortByOrder( - bookmarks.filter(b => !b.folderId), - bookmarkFallbackCompare - ).map(b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, null))); - - return [...folderNodes, ...ungrouped]; - } + get dropMimeTypes(): string[] { + return [BOOKMARK_DND_MIME_TYPE]; + } - get dropMimeTypes(): string[] { - return [BOOKMARK_DND_MIME_TYPE]; - } + get dragMimeTypes(): string[] { + return [BOOKMARK_DND_MIME_TYPE]; + } - get dragMimeTypes(): string[] { - return [BOOKMARK_DND_MIME_TYPE]; + handleDrag(source: readonly TreeNode[], dataTransfer: vscode.DataTransfer): void { + const item = source[0]; + if (!item) { + return; } - - handleDrag(source: readonly TreeNode[], dataTransfer: vscode.DataTransfer): void { - const item = source[0]; - if (!item) { - return; - } - const payload = item instanceof BookmarkTreeItem - ? { kind: 'bookmark' as const, id: item.bookmark.id } - : { kind: 'folder' as const, id: item.folder.id }; - dataTransfer.set(BOOKMARK_DND_MIME_TYPE, new vscode.DataTransferItem(JSON.stringify(payload))); + const payload = + item instanceof BookmarkTreeItem + ? { kind: 'bookmark' as const, id: item.bookmark.id } + : { kind: 'folder' as const, id: item.folder.id }; + dataTransfer.set(BOOKMARK_DND_MIME_TYPE, new vscode.DataTransferItem(JSON.stringify(payload))); + } + + async handleDrop(target: TreeNode | undefined, dataTransfer: vscode.DataTransfer): Promise { + const transferItem = dataTransfer.get(BOOKMARK_DND_MIME_TYPE); + if (!transferItem) { + return; } - - async handleDrop(target: TreeNode | undefined, dataTransfer: vscode.DataTransfer): Promise { - const transferItem = dataTransfer.get(BOOKMARK_DND_MIME_TYPE); - if (!transferItem) { - return; - } - const payload = JSON.parse(await transferItem.asString()) as { kind: 'bookmark' | 'folder'; id: string }; - if (payload.kind === 'folder') { - dropFolder(this.store, payload.id, target); - } else { - dropBookmark(this.store, payload.id, target, this.viewMode); - } + const payload = JSON.parse(await transferItem.asString()) as { + kind: 'bookmark' | 'folder'; + id: string; + }; + if (payload.kind === 'folder') { + dropFolder(this.store, payload.id, target); + } else { + dropBookmark(this.store, payload.id, target, this.viewMode); } + } } /** Case-insensitive substring match against a bookmark's label, relative path, and workspace folder name. */ export function matchesSearchFilter(bookmark: Bookmark, query: string): boolean { - const needle = query.trim().toLowerCase(); - if (!needle) { - return true; - } - return [bookmark.label, bookmark.relativePath, bookmark.workspaceFolderName].some(field => - field.toLowerCase().includes(needle) - ); + const needle = query.trim().toLowerCase(); + if (!needle) { + return true; + } + return [bookmark.label, bookmark.relativePath, bookmark.workspaceFolderName].some((field) => + field.toLowerCase().includes(needle), + ); } -export function describeBookmark(bookmark: Bookmark, isMultiRoot: boolean, folderName: string | null): string[] { - const parts: string[] = []; - if (folderName) { - parts.push(folderName); - } - if (isMultiRoot) { - parts.push(bookmark.workspaceFolderName); - } - parts.push(bookmark.relativePath); - if (bookmark.tags && bookmark.tags.length > 0) { - parts.push(bookmark.tags.map(tag => `#${tag}`).join(' ')); - } - return parts; +export function describeBookmark( + bookmark: Bookmark, + isMultiRoot: boolean, + folderName: string | null, +): string[] { + const parts: string[] = []; + if (folderName) { + parts.push(folderName); + } + if (isMultiRoot) { + parts.push(bookmark.workspaceFolderName); + } + parts.push(bookmark.relativePath); + if (bookmark.tags && bookmark.tags.length > 0) { + parts.push(bookmark.tags.map((tag) => `#${tag}`).join(' ')); + } + return parts; } export function toBookmark(uri: vscode.Uri): Bookmark { - const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); - const workspaceFolderName = workspaceFolder?.name ?? 'Workspace'; - const relativePath = workspaceFolder ? vscode.workspace.asRelativePath(uri, false) : uri.fsPath; - const label = relativePath.split('/').pop() || relativePath; - - return { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - uri: uri.toString(), - label, - relativePath, - workspaceFolderName, - folderId: null, - createdAt: Date.now() - }; + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + const workspaceFolderName = workspaceFolder?.name ?? 'Workspace'; + const relativePath = workspaceFolder ? vscode.workspace.asRelativePath(uri, false) : uri.fsPath; + const label = relativePath.split('/').pop() || relativePath; + + return { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + uri: uri.toString(), + label, + relativePath, + workspaceFolderName, + folderId: null, + createdAt: Date.now(), + }; } export function addActiveFileBookmark(store: BookmarkStore) { - const editor = vscode.window.activeTextEditor; - if (!editor) { - vscode.window.showWarningMessage('Open a file to bookmark it.'); - return; - } - store.addBookmark(toBookmark(editor.document.uri)); + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage('Open a file to bookmark it.'); + return; + } + store.addBookmark(toBookmark(editor.document.uri)); } -export function addBookmarksForUris(store: BookmarkStore, uri: vscode.Uri | undefined, uris: vscode.Uri[] | undefined) { - const targets = uris && uris.length > 0 ? uris : uri ? [uri] : []; - for (const target of targets) { - store.addBookmark(toBookmark(target)); - } +export function addBookmarksForUris( + store: BookmarkStore, + uri: vscode.Uri | undefined, + uris: vscode.Uri[] | undefined, +) { + const targets = uris && uris.length > 0 ? uris : uri ? [uri] : []; + for (const target of targets) { + store.addBookmark(toBookmark(target)); + } } export async function openBookmark(bookmark: Bookmark) { - try { - const uri = vscode.Uri.parse(bookmark.uri); - const document = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(document, { preview: false }); - } catch { - vscode.window.showErrorMessage(`Could not open "${bookmark.relativePath}". The file may have been moved or deleted.`); - } + try { + const uri = vscode.Uri.parse(bookmark.uri); + const document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document, { preview: false }); + } catch { + vscode.window.showErrorMessage( + `Could not open "${bookmark.relativePath}". The file may have been moved or deleted.`, + ); + } } export async function openAllInFolder(store: BookmarkStore, item: FolderGroupItem) { - if (item.bookmarks.length === 0) { - vscode.window.showInformationMessage(`"${item.folder.name}" has no bookmarks.`); - return; - } - for (const bookmark of item.bookmarks) { - await openBookmark(bookmark); - } + if (item.bookmarks.length === 0) { + vscode.window.showInformationMessage(`"${item.folder.name}" has no bookmarks.`); + return; + } + for (const bookmark of item.bookmarks) { + await openBookmark(bookmark); + } } export async function createFolder(store: BookmarkStore) { - const name = await vscode.window.showInputBox({ - prompt: 'New bookmark folder name', - placeHolder: 'e.g. Backend, In Review, TODO', - validateInput: value => (value.trim().length === 0 ? 'Folder name cannot be empty.' : undefined) - }); - if (name) { - store.createFolder(name.trim()); - } + const name = await vscode.window.showInputBox({ + prompt: 'New bookmark folder name', + placeHolder: 'e.g. Backend, In Review, TODO', + validateInput: (value) => + value.trim().length === 0 ? 'Folder name cannot be empty.' : undefined, + }); + if (name) { + store.createFolder(name.trim()); + } +} + +/** Creates a subfolder directly under `item.folder`, skipping the parent picker since the parent is implied by the context menu. */ +export async function createSubfolder(store: BookmarkStore, item: FolderGroupItem) { + if (!canNestUnder(store.getAllFolders(), item.folder.id)) { + vscode.window.showWarningMessage( + `Can't create a subfolder here — maximum folder depth (${MAX_FOLDER_DEPTH}) reached.`, + ); + return; + } + const name = await vscode.window.showInputBox({ + prompt: `New subfolder in "${item.folder.name}"`, + placeHolder: 'e.g. Auth Service, Billing Service', + validateInput: (value) => + value.trim().length === 0 ? 'Folder name cannot be empty.' : undefined, + }); + if (name) { + store.createFolder(name.trim(), item.folder.id); + } +} + +/** Reparents an existing folder under a chosen parent (or back to the root), via a picker that excludes the folder itself, its descendants, and any parent that would exceed MAX_FOLDER_DEPTH. */ +export async function moveFolderToParent(store: BookmarkStore, item: FolderGroupItem) { + const parentId = await pickParentFolder( + store, + `Move "${item.folder.name}" to...`, + item.folder.id, + ); + if (parentId === undefined) { + return; + } + store.moveFolderToParent(item.folder.id, parentId); } export async function renameFolder(store: BookmarkStore, item: FolderGroupItem) { - const name = await vscode.window.showInputBox({ - prompt: 'Rename bookmark folder', - value: item.folder.name, - validateInput: value => (value.trim().length === 0 ? 'Folder name cannot be empty.' : undefined) - }); - if (name) { - store.renameFolder(item.folder.id, name.trim()); - } + const name = await vscode.window.showInputBox({ + prompt: 'Rename bookmark folder', + value: item.folder.name, + validateInput: (value) => + value.trim().length === 0 ? 'Folder name cannot be empty.' : undefined, + }); + if (name) { + store.renameFolder(item.folder.id, name.trim()); + } } export async function renameBookmark(store: BookmarkStore, item: BookmarkTreeItem) { - const label = await vscode.window.showInputBox({ - prompt: 'Rename bookmark', - value: item.bookmark.label, - validateInput: value => (value.trim().length === 0 ? 'Bookmark label cannot be empty.' : undefined) - }); - if (label) { - store.renameBookmark(item.bookmark.id, label.trim()); - } + const label = await vscode.window.showInputBox({ + prompt: 'Rename bookmark', + value: item.bookmark.label, + validateInput: (value) => + value.trim().length === 0 ? 'Bookmark label cannot be empty.' : undefined, + }); + if (label) { + store.renameBookmark(item.bookmark.id, label.trim()); + } } const ADD_NEW_TAG_PICK = '$(add) Add New Tag...'; /** Opens a multi-select quick-pick of existing tags (pre-checked to the bookmark's current ones), with a fallback input box to type a brand-new tag. */ -export async function editBookmarkTags(store: BookmarkStore, item: BookmarkTreeItem): Promise { - const currentTags = new Set(item.bookmark.tags ?? []); - const items: vscode.QuickPickItem[] = [ - ...store.getAllTags().map(tag => ({ label: tag, picked: currentTags.has(tag) })), - { label: ADD_NEW_TAG_PICK } - ]; - - const picked = await vscode.window.showQuickPick(items, { - placeHolder: `Tags for "${item.bookmark.label}"`, - canPickMany: true +export async function editBookmarkTags( + store: BookmarkStore, + item: BookmarkTreeItem, +): Promise { + const currentTags = new Set(item.bookmark.tags ?? []); + const items: vscode.QuickPickItem[] = [ + ...store.getAllTags().map((tag) => ({ label: tag, picked: currentTags.has(tag) })), + { label: ADD_NEW_TAG_PICK }, + ]; + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: `Tags for "${item.bookmark.label}"`, + canPickMany: true, + }); + if (!picked) { + return; + } + + const tags = picked.map((p) => p.label).filter((label) => label !== ADD_NEW_TAG_PICK); + + if (picked.some((p) => p.label === ADD_NEW_TAG_PICK)) { + const newTag = await vscode.window.showInputBox({ + prompt: 'New tag name', + validateInput: (value) => + value.trim().length === 0 ? 'Tag name cannot be empty.' : undefined, }); - if (!picked) { - return; + if (newTag) { + tags.push(newTag.trim()); } + } - const tags = picked.map(p => p.label).filter(label => label !== ADD_NEW_TAG_PICK); - - if (picked.some(p => p.label === ADD_NEW_TAG_PICK)) { - const newTag = await vscode.window.showInputBox({ - prompt: 'New tag name', - validateInput: value => (value.trim().length === 0 ? 'Tag name cannot be empty.' : undefined) - }); - if (newTag) { - tags.push(newTag.trim()); - } - } - - store.setBookmarkTags(item.bookmark.id, [...new Set(tags)]); + store.setBookmarkTags(item.bookmark.id, [...new Set(tags)]); } /** Prompts for a tag to filter the tree by (or to clear the active filter). No-ops with a message if no tags exist yet. */ -export async function filterByTag(store: BookmarkStore, provider: BookmarksTreeProvider): Promise { - const tags = store.getAllTags(); - if (tags.length === 0) { - vscode.window.showInformationMessage('No tags yet — tag a bookmark first.'); - return; - } - - const CLEAR_FILTER_PICK = '$(clear-all) Clear Filter'; - const items = provider.getTagFilter() ? [CLEAR_FILTER_PICK, ...tags] : tags; - const picked = await vscode.window.showQuickPick(items, { placeHolder: 'Filter bookmarks by tag' }); - if (picked === undefined) { - return; - } - provider.setTagFilter(picked === CLEAR_FILTER_PICK ? null : picked); +export async function filterByTag( + store: BookmarkStore, + provider: BookmarksTreeProvider, +): Promise { + const tags = store.getAllTags(); + if (tags.length === 0) { + vscode.window.showInformationMessage('No tags yet — tag a bookmark first.'); + return; + } + + const CLEAR_FILTER_PICK = '$(clear-all) Clear Filter'; + const items = provider.getTagFilter() ? [CLEAR_FILTER_PICK, ...tags] : tags; + const picked = await vscode.window.showQuickPick(items, { + placeHolder: 'Filter bookmarks by tag', + }); + if (picked === undefined) { + return; + } + provider.setTagFilter(picked === CLEAR_FILTER_PICK ? null : picked); } /** @@ -527,83 +803,158 @@ export async function filterByTag(store: BookmarkStore, provider: BookmarksTreeP * without accepting) clears it. Returns the input box so tests can drive it without a real UI. */ export function filterBookmarks(provider: BookmarksTreeProvider): vscode.InputBox { - const inputBox = vscode.window.createInputBox(); - inputBox.placeholder = 'Filter bookmarks by name, path, or repo'; - inputBox.value = provider.getSearchFilter() ?? ''; - - let accepted = false; - inputBox.onDidChangeValue(value => provider.setSearchFilter(value)); - inputBox.onDidAccept(() => { - accepted = true; - inputBox.hide(); - }); - inputBox.onDidHide(() => { - if (!accepted) { - provider.setSearchFilter(null); - } - inputBox.dispose(); - }); + const inputBox = vscode.window.createInputBox(); + inputBox.placeholder = 'Filter bookmarks by name, path, or repo'; + inputBox.value = provider.getSearchFilter() ?? ''; + + let accepted = false; + inputBox.onDidChangeValue((value) => provider.setSearchFilter(value)); + inputBox.onDidAccept(() => { + accepted = true; + inputBox.hide(); + }); + inputBox.onDidHide(() => { + if (!accepted) { + provider.setSearchFilter(null); + } + inputBox.dispose(); + }); - inputBox.show(); - return inputBox; + inputBox.show(); + return inputBox; } export async function deleteFolder(store: BookmarkStore, item: FolderGroupItem) { + if (item.bookmarks.length > 0 || item.childFolders.length > 0) { + const parts: string[] = []; if (item.bookmarks.length > 0) { - const confirm = await vscode.window.showWarningMessage( - `Delete folder "${item.folder.name}"? ${item.bookmarks.length} bookmark(s) will move to the root list.`, - { modal: true }, - 'Delete' - ); - if (confirm !== 'Delete') { - return; - } + parts.push(`${item.bookmarks.length} bookmark(s) will move to the root list`); + } + if (item.childFolders.length > 0) { + parts.push(`${item.childFolders.length} subfolder(s) will move to the root`); + } + const confirm = await vscode.window.showWarningMessage( + `Delete folder "${item.folder.name}"? ${parts.join(' and ')}.`, + { modal: true }, + 'Delete', + ); + if (confirm !== 'Delete') { + return; } - store.deleteFolder(item.folder.id); + } + store.deleteFolder(item.folder.id); } const NEW_FOLDER_PICK = '$(new-folder) New Folder...'; const NO_FOLDER_PICK = '$(circle-slash) No Folder (root)'; /** Prompts the user to choose a bookmark folder. Returns `undefined` if cancelled, `null` for root. */ -export async function pickFolder(store: BookmarkStore, placeHolder: string): Promise { - const folders = store.getAllFolders(); - const picked = await vscode.window.showQuickPick( - [NO_FOLDER_PICK, ...folders.map(f => f.name), NEW_FOLDER_PICK], - { placeHolder } - ); - if (!picked) { - return undefined; - } - - if (picked === NO_FOLDER_PICK) { - return null; +export async function pickFolder( + store: BookmarkStore, + placeHolder: string, +): Promise { + const folders = store.getAllFolders(); + const breadcrumbById = new Map(folders.map((f) => [f.id, folderBreadcrumb(folders, f.id)])); + const picked = await vscode.window.showQuickPick( + [NO_FOLDER_PICK, ...folders.map((f) => breadcrumbById.get(f.id) as string), NEW_FOLDER_PICK], + { placeHolder }, + ); + if (!picked) { + return undefined; + } + + if (picked === NO_FOLDER_PICK) { + return null; + } + + if (picked === NEW_FOLDER_PICK) { + const name = await vscode.window.showInputBox({ + prompt: 'New bookmark folder name', + validateInput: (value) => + value.trim().length === 0 ? 'Folder name cannot be empty.' : undefined, + }); + if (!name) { + return undefined; } + return store.createFolder(name.trim()).id; + } - if (picked === NEW_FOLDER_PICK) { - const name = await vscode.window.showInputBox({ - prompt: 'New bookmark folder name', - validateInput: value => (value.trim().length === 0 ? 'Folder name cannot be empty.' : undefined) - }); - if (!name) { - return undefined; - } - return store.createFolder(name.trim()).id; - } + return folders.find((f) => breadcrumbById.get(f.id) === picked)?.id ?? undefined; +} - return folders.find(f => f.name === picked)?.id ?? undefined; +const NO_PARENT_PICK = '$(circle-slash) No Parent (root)'; + +/** Prompts the user to choose a parent folder for another folder. Returns `undefined` if cancelled, `null` for root. `excludeFolderId` excludes that folder and its descendants (for reparenting), and folders where nesting would exceed MAX_FOLDER_DEPTH are left out entirely. */ +export async function pickParentFolder( + store: BookmarkStore, + placeHolder: string, + excludeFolderId?: string, +): Promise { + const eligible = eligibleParentFolders(store, { excludeFolderId }); + const allFolders = store.getAllFolders(); + const breadcrumbById = new Map(eligible.map((f) => [f.id, folderBreadcrumb(allFolders, f.id)])); + + const picked = await vscode.window.showQuickPick( + [NO_PARENT_PICK, ...eligible.map((f) => breadcrumbById.get(f.id) as string)], + { + placeHolder, + }, + ); + if (!picked) { + return undefined; + } + if (picked === NO_PARENT_PICK) { + return null; + } + return eligible.find((f) => breadcrumbById.get(f.id) === picked)?.id ?? undefined; } -/** Reorders folders by moving `draggedId` to just before the folder `target` represents (or to the end if dropped elsewhere). */ +/** + * Handles a folder dropped onto `target`: dropping onto another folder always nests the dragged + * folder as that folder's last child (moving folders can't be reordered onto each other the way + * bookmarks are — there's no way to tell "reorder before" from "nest into" from a single drop + * target, so nesting wins since it's the primary reason to drag a folder here). A no-op message + * is shown if nesting there would exceed MAX_FOLDER_DEPTH or create a cycle. Dropping on empty + * space moves the folder to the root and appends it at the end of the root-level list. + */ export function dropFolder(store: BookmarkStore, draggedId: string, target: TreeNode | undefined) { - if (target instanceof BookmarkTreeItem) { - return; + const folders = store.getAllFolders(); + const dragged = folders.find((f) => f.id === draggedId); + if (!dragged) { + return; + } + + if (target instanceof BookmarkTreeItem) { + return; + } + + if (target instanceof FolderGroupItem) { + if ( + target.folder.id === draggedId || + isDescendantFolder(folders, draggedId, target.folder.id) + ) { + return; } - const beforeId = target instanceof FolderGroupItem ? target.folder.id : null; - if (beforeId === draggedId) { - return; + const height = folderSubtreeHeight(folders, draggedId); + if (!canNestUnder(folders, target.folder.id, height)) { + vscode.window.showWarningMessage( + `Can't nest "${dragged.name}" there — maximum folder depth (${MAX_FOLDER_DEPTH}) reached.`, + ); + return; } - store.reorderFolders(computeReorderedIds(store.getAllFolders(), draggedId, beforeId, folderFallbackCompare)); + if ((dragged.parentId ?? null) !== target.folder.id) { + store.moveFolderToParent(draggedId, target.folder.id); + } + const scope = store.getAllFolders().filter((f) => (f.parentId ?? null) === target.folder.id); + store.reorderFolders(computeReorderedIds(scope, draggedId, null, folderFallbackCompare)); + return; + } + + if ((dragged.parentId ?? null) !== null) { + store.moveFolderToParent(draggedId, null); + } + const scope = store.getAllFolders().filter((f) => (f.parentId ?? null) === null); + store.reorderFolders(computeReorderedIds(scope, draggedId, null, folderFallbackCompare)); } /** @@ -612,57 +963,75 @@ export function dropFolder(store: BookmarkStore, draggedId: string, target: Tree * moving folders too in tree view (list view reorders in place without touching folderId); * dropping on empty space appends to the end of the root/list scope. */ -export function dropBookmark(store: BookmarkStore, draggedId: string, target: TreeNode | undefined, viewMode: ViewMode) { - const dragged = store.getAllBookmarks().find(b => b.id === draggedId); - if (!dragged) { - return; - } - - if (target instanceof FolderGroupItem) { - if (dragged.folderId !== target.folder.id) { - store.moveBookmarkToFolder(draggedId, target.folder.id); - } - const scope = store.getAllBookmarks().filter(b => b.folderId === target.folder.id); - store.reorderBookmarks(computeReorderedIds(scope, draggedId, null, bookmarkFallbackCompare)); - return; - } - - if (target instanceof BookmarkTreeItem && target.bookmark.id === draggedId) { - return; +export function dropBookmark( + store: BookmarkStore, + draggedId: string, + target: TreeNode | undefined, + viewMode: ViewMode, +) { + const dragged = store.getAllBookmarks().find((b) => b.id === draggedId); + if (!dragged) { + return; + } + + if (target instanceof FolderGroupItem) { + if (dragged.folderId !== target.folder.id) { + store.moveBookmarkToFolder(draggedId, target.folder.id); } - - const beforeId = target instanceof BookmarkTreeItem ? target.bookmark.id : null; - // List view is a flat scope: reorder in place without ever reassigning folderId. - const destinationFolderId = - viewMode === 'list' ? dragged.folderId : target instanceof BookmarkTreeItem ? target.bookmark.folderId : null; - - if (destinationFolderId !== dragged.folderId) { - store.moveBookmarkToFolder(draggedId, destinationFolderId); - } - - const scope = store.getAllBookmarks().filter(b => (viewMode === 'list' ? true : b.folderId === destinationFolderId)); - store.reorderBookmarks(computeReorderedIds(scope, draggedId, beforeId, bookmarkFallbackCompare)); + const scope = store.getAllBookmarks().filter((b) => b.folderId === target.folder.id); + store.reorderBookmarks(computeReorderedIds(scope, draggedId, null, bookmarkFallbackCompare)); + return; + } + + if (target instanceof BookmarkTreeItem && target.bookmark.id === draggedId) { + return; + } + + const beforeId = target instanceof BookmarkTreeItem ? target.bookmark.id : null; + // List view is a flat scope: reorder in place without ever reassigning folderId. + const destinationFolderId = + viewMode === 'list' + ? dragged.folderId + : target instanceof BookmarkTreeItem + ? target.bookmark.folderId + : null; + + if (destinationFolderId !== dragged.folderId) { + store.moveBookmarkToFolder(draggedId, destinationFolderId); + } + + const scope = store + .getAllBookmarks() + .filter((b) => (viewMode === 'list' ? true : b.folderId === destinationFolderId)); + store.reorderBookmarks(computeReorderedIds(scope, draggedId, beforeId, bookmarkFallbackCompare)); } export async function moveToFolder(store: BookmarkStore, item: BookmarkTreeItem) { - const folderId = await pickFolder(store, `Move "${item.bookmark.label}" to...`); - if (folderId === undefined) { - return; - } - store.moveBookmarkToFolder(item.bookmark.id, folderId); + const folderId = await pickFolder(store, `Move "${item.bookmark.label}" to...`); + if (folderId === undefined) { + return; + } + store.moveBookmarkToFolder(item.bookmark.id, folderId); } -export async function addBookmarksToFolder(store: BookmarkStore, uri: vscode.Uri | undefined, uris: vscode.Uri[] | undefined) { - const targets = uris && uris.length > 0 ? uris : uri ? [uri] : []; - if (targets.length === 0) { - return; - } - const placeHolder = targets.length > 1 ? `Add ${targets.length} files to...` : `Add "${toBookmark(targets[0]).label}" to...`; - const folderId = await pickFolder(store, placeHolder); - if (folderId === undefined) { - return; - } - for (const target of targets) { - store.addBookmark({ ...toBookmark(target), folderId }); - } +export async function addBookmarksToFolder( + store: BookmarkStore, + uri: vscode.Uri | undefined, + uris: vscode.Uri[] | undefined, +) { + const targets = uris && uris.length > 0 ? uris : uri ? [uri] : []; + if (targets.length === 0) { + return; + } + const placeHolder = + targets.length > 1 + ? `Add ${targets.length} files to...` + : `Add "${toBookmark(targets[0]).label}" to...`; + const folderId = await pickFolder(store, placeHolder); + if (folderId === undefined) { + return; + } + for (const target of targets) { + store.addBookmark({ ...toBookmark(target), folderId }); + } } diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..2e312ca --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,15 @@ +pre-commit: + parallel: true + commands: + lint-types: + glob: '*.{ts,mts,cts}' + run: npm run lint:types + lint-style: + glob: '*.{ts,mts,cts,js,mjs,cjs}' + run: npx eslint {staged_files} + format: + glob: '*.{ts,mts,cts,js,mjs,cjs,json,md,yml,yaml}' + run: npx prettier --check {staged_files} + test: + glob: '*.{ts,mts,cts}' + run: npx vitest run diff --git a/package-lock.json b/package-lock.json index 04737e5..873b31c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,9 @@ "": { "name": "workspace-file-bookmarks", "version": "1.8.0", + "hasInstallScript": true, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/mocha": "^10.0.10", "@types/node": "^26.2.0", "@types/vscode": "^1.134.0", @@ -16,7 +18,12 @@ "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.9.2", "esbuild": "^0.28.2", + "eslint": "^10.9.0", + "eslint-config-prettier": "^10.1.8", + "lefthook": "^2.1.10", + "prettier": "^3.9.6", "typescript": "~6.0.3", + "typescript-eslint": "^8.67.0", "vitest": "^4.1.11" }, "engines": { @@ -743,6 +750,200 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1492,6 +1693,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1506,6 +1714,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mocha": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", @@ -1544,6 +1759,236 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typespec/ts-http-runtime": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.8.tgz", @@ -1936,6 +2381,29 @@ "win32" ] }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2749,6 +3217,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/default-browser": { "version": "5.5.1", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", @@ -3123,6 +3598,191 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.0.tgz", + "integrity": "sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3133,6 +3793,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -3175,13 +3845,40 @@ "micromatch": "^4.0.8" }, "engines": { - "node": ">=8.6.0" + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "dev": true, "funding": [ { @@ -3223,6 +3920,19 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3263,6 +3973,27 @@ "flat": "cli.js" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3434,16 +4165,16 @@ } }, "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { - "is-glob": "^4.0.1" + "is-glob": "^4.0.3" }, "engines": { - "node": ">= 6" + "node": ">=10.13.0" } }, "node_modules/globby": { @@ -3467,6 +4198,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globby/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3666,9 +4407,9 @@ "optional": true }, "node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -3682,6 +4423,16 @@ "dev": true, "license": "MIT" }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/index-to-position": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", @@ -3980,6 +4731,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -3987,6 +4745,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4092,6 +4857,179 @@ "prebuild-install": "^7.0.1" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lefthook": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.10.tgz", + "integrity": "sha512-K7mM4WoqMwqfXYK11EHy+lSH1uW8XHni3Yn/bSqyerPkUPygGdf3xn18JoV5HyA06xuQL3ofGAOjG01QX9oJ4w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "lefthook": "bin/index.js" + }, + "optionalDependencies": { + "lefthook-darwin-arm64": "2.1.10", + "lefthook-darwin-x64": "2.1.10", + "lefthook-freebsd-arm64": "2.1.10", + "lefthook-freebsd-x64": "2.1.10", + "lefthook-linux-arm64": "2.1.10", + "lefthook-linux-x64": "2.1.10", + "lefthook-openbsd-arm64": "2.1.10", + "lefthook-openbsd-x64": "2.1.10", + "lefthook-windows-arm64": "2.1.10", + "lefthook-windows-x64": "2.1.10" + } + }, + "node_modules/lefthook-darwin-arm64": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.10.tgz", + "integrity": "sha512-nw+X8wRNDoUUV6WSteyKBbcLySq+fsmZt5WV/s50ZJpysmsDKJOUMln6SllNfP+60dzUahAO7REco/2633BsLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/lefthook-darwin-x64": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.10.tgz", + "integrity": "sha512-KQ/bHmvpkFdHMn4pZnUdTf+GuSC+aBBgBTxZT4GW+6cSf+qbErKZBhK7cH6BmILsvx43+VzEArvHYY7YOfRFOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/lefthook-freebsd-arm64": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.10.tgz", + "integrity": "sha512-8su6DwydP7+pv7kG0zCtjphqsw4ouOnfexRUErapy5GTxYBoUOhYz3RSHTSWNRsK6W4jva7FPUh2Lp5/PSn30w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/lefthook-freebsd-x64": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.10.tgz", + "integrity": "sha512-GeAJEFxko3Lk+AsnS3NleAFrpyMLFUKOlgJvPKuU0xHwVEI/z+ZoCcmuO0BX+4CS0NLbZhC/YQAvBASqDvvVdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/lefthook-linux-arm64": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.10.tgz", + "integrity": "sha512-1sHTCmpTWjVMs+yKPBLRNT1kuuIr1yjietlk7rCB6wFPVOS6Ph3o2zPFH2AvW1UymHlqwyHXzBr9EtDpQ7j1mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/lefthook-linux-x64": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.10.tgz", + "integrity": "sha512-z/VlRB3bh6mBvW3r1rwnJ5vP8z+Krx5gJzkZ4veDXh+6FlRTx8wtd3g3fllOv/yZMxkgmL3fQoFXv05Esa7vBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/lefthook-openbsd-arm64": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.10.tgz", + "integrity": "sha512-430zL8sSIKw5P0YXGG6PB+eAhHa06n0PXuaERaAQE4Ss3odfqwnl5Mq9hQmkEnOS1EGiQEKkd0UHv/i4PtMNIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/lefthook-openbsd-x64": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.10.tgz", + "integrity": "sha512-bgkO8PphGZVDhQgCJ524aYYPI5491pVmCiLPGjBIo1AvOSlIyw4N1Y+1C3QfqwEmechzw+Aq16SNc8pqv6UuXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/lefthook-windows-arm64": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.10.tgz", + "integrity": "sha512-5Q6etF0Fla2DDA4ilDySrdNgiR5+W7cJZwnZ69Je3kvWCaWm4wnkuc8FEdjp3kiL2x3ZXipdI00f5vpO8aWmog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/lefthook-windows-x64": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.10.tgz", + "integrity": "sha512-c/XH8YZtylG4XaxzqFfXluvq2LXq2W/p54Bnzn3+Z7E5X2Fk3JlFJAibulMbIt2+w8T7UI/r97ok5GqE4kGaeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -4102,6 +5040,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -5007,6 +5959,13 @@ "license": "MIT", "optional": true }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-abi": { "version": "3.94.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", @@ -5164,6 +6123,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", @@ -5581,6 +6558,32 @@ "node": ">=10" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -5600,6 +6603,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -6662,6 +7675,19 @@ "node": ">=8.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6693,6 +7719,19 @@ "node": "*" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -6732,6 +7771,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", @@ -6786,6 +7849,16 @@ "node": ">= 10.0.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/url-join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", @@ -7090,6 +8163,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/workerpool": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", diff --git a/package.json b/package.json index e0a5534..6703c06 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,17 @@ "category": "Workspace File Bookmarks", "icon": "$(new-folder)" }, + { + "command": "workspace-file-bookmarks.createSubfolder", + "title": "New Subfolder", + "category": "Workspace File Bookmarks", + "icon": "$(new-folder)" + }, + { + "command": "workspace-file-bookmarks.moveFolderToParent", + "title": "Move to Parent Folder...", + "category": "Workspace File Bookmarks" + }, { "command": "workspace-file-bookmarks.renameFolder", "title": "Rename Folder", @@ -231,14 +242,24 @@ "group": "inline@1" }, { - "command": "workspace-file-bookmarks.deleteFolder", + "command": "workspace-file-bookmarks.createSubfolder", "when": "view == workspace-file-bookmarks-view && viewItem == bookmarkFolder", "group": "inline@2" }, + { + "command": "workspace-file-bookmarks.deleteFolder", + "when": "view == workspace-file-bookmarks-view && viewItem == bookmarkFolder", + "group": "inline@3" + }, { "command": "workspace-file-bookmarks.renameFolder", "when": "view == workspace-file-bookmarks-view && viewItem == bookmarkFolder", "group": "1_modification" + }, + { + "command": "workspace-file-bookmarks.moveFolderToParent", + "when": "view == workspace-file-bookmarks-view && viewItem == bookmarkFolder", + "group": "1_modification" } ], "editor/title": [ @@ -271,9 +292,14 @@ "build": "esbuild extension.ts --bundle --platform=node --format=cjs --sourcemap --external:vscode --outfile=dist/extension.cjs", "watch": "esbuild extension.ts --bundle --platform=node --format=cjs --sourcemap --external:vscode --outfile=dist/extension.cjs --watch", "vscode:prepublish": "npm run build", + "postinstall": "lefthook install", "package": "bash scripts/rebuild.sh", "clean": "rm -rf dist", - "lint": "tsc --noEmit", + "lint": "npm run lint:types && npm run lint:style", + "lint:types": "tsc --noEmit", + "lint:style": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", "test": "vitest run", "test:coverage": "vitest run --coverage", "pretest:e2e": "npm run build && esbuild test/e2e/*.test.ts --bundle --outdir=out-e2e --platform=node --format=cjs --external:vscode --sourcemap", @@ -284,6 +310,7 @@ "serialize-javascript": "^7.1.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/mocha": "^10.0.10", "@types/node": "^26.2.0", "@types/vscode": "^1.134.0", @@ -292,7 +319,12 @@ "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.9.2", "esbuild": "^0.28.2", + "eslint": "^10.9.0", + "eslint-config-prettier": "^10.1.8", + "lefthook": "^2.1.10", + "prettier": "^3.9.6", "typescript": "~6.0.3", + "typescript-eslint": "^8.67.0", "vitest": "^4.1.11" } } diff --git a/test/activate.test.ts b/test/activate.test.ts index 6e7ba3f..251c239 100644 --- a/test/activate.test.ts +++ b/test/activate.test.ts @@ -4,53 +4,69 @@ import { createFakeContext } from './fakeContext'; import { commands, window } from './vscode-mock'; describe('activate', () => { - it('creates the tree view and registers every command', () => { - const context = createFakeContext(); - (context as any).subscriptions = []; + it('creates the tree view and registers every command', () => { + const context = createFakeContext(); + (context as any).subscriptions = []; - activate(context as any); + activate(context as any); - expect(window.createTreeView).toHaveBeenCalledOnce(); - expect(commands.registerCommand).toHaveBeenCalledTimes(18); - expect((context as any).subscriptions).toHaveLength(19); // tree view + 18 commands - }); + expect(window.createTreeView).toHaveBeenCalledOnce(); + expect(commands.registerCommand).toHaveBeenCalledTimes(20); + expect((context as any).subscriptions).toHaveLength(21); // tree view + 20 commands + }); + + it('exercises every registered command handler at least once', async () => { + const context = createFakeContext(); + (context as any).subscriptions = []; + + activate(context as any); - it('exercises every registered command handler at least once', async () => { - const context = createFakeContext(); - (context as any).subscriptions = []; - - activate(context as any); - - const calls = commands.registerCommand.mock.calls as unknown as Array<[string, (...args: any[]) => unknown]>; - const handlers = new Map(calls); - const bookmark = { id: 'b', uri: 'file:///repo/a.ts', label: 'a.ts', relativePath: 'a.ts', workspaceFolderName: 'repo', folderId: null, createdAt: 0 }; - const folder = { id: 'f', name: 'Backend', createdAt: 0 }; - - await handlers.get('workspace-file-bookmarks.addBookmark')?.(); - await handlers.get('workspace-file-bookmarks.addBookmarkFromExplorer')?.(undefined, undefined); - await handlers.get('workspace-file-bookmarks.addBookmarkToFolder')?.(undefined, undefined); - await handlers.get('workspace-file-bookmarks.removeBookmark')?.({ bookmark }); - await handlers.get('workspace-file-bookmarks.renameBookmark')?.({ bookmark }); - await handlers.get('workspace-file-bookmarks.editTags')?.({ bookmark }); - await handlers.get('workspace-file-bookmarks.filterByTag')?.(); - await handlers.get('workspace-file-bookmarks.clearTagFilter')?.(); - await handlers.get('workspace-file-bookmarks.filterBookmarks')?.(); - await handlers.get('workspace-file-bookmarks.clearSearchFilter')?.(); - await handlers.get('workspace-file-bookmarks.openBookmark')?.(bookmark); - await handlers.get('workspace-file-bookmarks.createFolder')?.(); - await handlers.get('workspace-file-bookmarks.renameFolder')?.({ folder }); - await handlers.get('workspace-file-bookmarks.deleteFolder')?.({ folder, bookmarks: [] }); - await handlers.get('workspace-file-bookmarks.moveToFolder')?.({ bookmark }); - await handlers.get('workspace-file-bookmarks.openAllInFolder')?.({ folder, bookmarks: [] }); - await handlers.get('workspace-file-bookmarks.setViewModeList')?.(); - await handlers.get('workspace-file-bookmarks.setViewModeTree')?.(); - - expect(handlers.size).toBe(18); + const calls = commands.registerCommand.mock.calls as unknown as Array< + [string, (...args: any[]) => unknown] + >; + const handlers = new Map(calls); + const bookmark = { + id: 'b', + uri: 'file:///repo/a.ts', + label: 'a.ts', + relativePath: 'a.ts', + workspaceFolderName: 'repo', + folderId: null, + createdAt: 0, + }; + const folder = { id: 'f', name: 'Backend', createdAt: 0 }; + + await handlers.get('workspace-file-bookmarks.addBookmark')?.(); + await handlers.get('workspace-file-bookmarks.addBookmarkFromExplorer')?.(undefined, undefined); + await handlers.get('workspace-file-bookmarks.addBookmarkToFolder')?.(undefined, undefined); + await handlers.get('workspace-file-bookmarks.removeBookmark')?.({ bookmark }); + await handlers.get('workspace-file-bookmarks.renameBookmark')?.({ bookmark }); + await handlers.get('workspace-file-bookmarks.editTags')?.({ bookmark }); + await handlers.get('workspace-file-bookmarks.filterByTag')?.(); + await handlers.get('workspace-file-bookmarks.clearTagFilter')?.(); + await handlers.get('workspace-file-bookmarks.filterBookmarks')?.(); + await handlers.get('workspace-file-bookmarks.clearSearchFilter')?.(); + await handlers.get('workspace-file-bookmarks.openBookmark')?.(bookmark); + await handlers.get('workspace-file-bookmarks.createFolder')?.(); + await handlers.get('workspace-file-bookmarks.createSubfolder')?.({ folder }); + await handlers.get('workspace-file-bookmarks.moveFolderToParent')?.({ folder }); + await handlers.get('workspace-file-bookmarks.renameFolder')?.({ folder }); + await handlers.get('workspace-file-bookmarks.deleteFolder')?.({ + folder, + bookmarks: [], + childFolders: [], }); + await handlers.get('workspace-file-bookmarks.moveToFolder')?.({ bookmark }); + await handlers.get('workspace-file-bookmarks.openAllInFolder')?.({ folder, bookmarks: [] }); + await handlers.get('workspace-file-bookmarks.setViewModeList')?.(); + await handlers.get('workspace-file-bookmarks.setViewModeTree')?.(); + + expect(handlers.size).toBe(20); + }); }); describe('deactivate', () => { - it('is a no-op', () => { - expect(() => deactivate()).not.toThrow(); - }); + it('is a no-op', () => { + expect(() => deactivate()).not.toThrow(); + }); }); diff --git a/test/bookmarkStore.test.ts b/test/bookmarkStore.test.ts index 81aa061..6704a5d 100644 --- a/test/bookmarkStore.test.ts +++ b/test/bookmarkStore.test.ts @@ -1,194 +1,368 @@ import { describe, it, expect } from 'vitest'; -import { BookmarkStore, describeBookmark, matchesSearchFilter, type Bookmark } from '../extension'; +import { + BookmarkStore, + MAX_FOLDER_DEPTH, + canNestUnder, + describeBookmark, + eligibleParentFolders, + folderBreadcrumb, + folderSubtreeHeight, + isDescendantFolder, + matchesSearchFilter, + type Bookmark, + type BookmarkFolder, +} from '../extension'; import { createFakeContext } from './fakeContext'; function makeBookmark(overrides: Partial = {}): Bookmark { - return { - id: 'id-1', - uri: 'file:///repo/src/a.ts', - label: 'a.ts', - relativePath: 'src/a.ts', - workspaceFolderName: 'repo', - folderId: null, - createdAt: 0, - ...overrides - }; + return { + id: 'id-1', + uri: 'file:///repo/src/a.ts', + label: 'a.ts', + relativePath: 'src/a.ts', + workspaceFolderName: 'repo', + folderId: null, + createdAt: 0, + ...overrides, + }; } function newStore() { - // BookmarkStore only touches context.workspaceState; the fake covers that. - return new BookmarkStore(createFakeContext() as any); + // BookmarkStore only touches context.workspaceState; the fake covers that. + return new BookmarkStore(createFakeContext() as any); } describe('BookmarkStore', () => { - it('starts empty', () => { - const store = newStore(); - expect(store.getAllBookmarks()).toEqual([]); - expect(store.getAllFolders()).toEqual([]); - }); - - it('adds a bookmark', () => { - const store = newStore(); - store.addBookmark(makeBookmark()); - expect(store.getAllBookmarks()).toHaveLength(1); - }); - - it('does not add a duplicate bookmark for the same uri', () => { - const store = newStore(); - store.addBookmark(makeBookmark({ id: 'a' })); - store.addBookmark(makeBookmark({ id: 'b' })); - const all = store.getAllBookmarks(); - expect(all).toHaveLength(1); - expect(all[0].id).toBe('a'); - }); - - it('removes a bookmark by id', () => { - const store = newStore(); - store.addBookmark(makeBookmark({ id: 'a' })); - store.removeBookmark('a'); - expect(store.getAllBookmarks()).toEqual([]); - }); - - it('moves a bookmark to a folder', () => { - const store = newStore(); - store.addBookmark(makeBookmark({ id: 'a', folderId: null })); - store.moveBookmarkToFolder('a', 'folder-1'); - expect(store.getAllBookmarks()[0].folderId).toBe('folder-1'); - }); - - it('renames a bookmark label', () => { - const store = newStore(); - store.addBookmark(makeBookmark({ id: 'a', label: 'a.ts' })); - store.renameBookmark('a', 'My File'); - expect(store.getAllBookmarks()[0].label).toBe('My File'); - }); - - it('sets tags on a bookmark', () => { - const store = newStore(); - store.addBookmark(makeBookmark({ id: 'a' })); - store.setBookmarkTags('a', ['auth', 'review']); - expect(store.getAllBookmarks()[0].tags).toEqual(['auth', 'review']); - }); - - it('collects every distinct tag across bookmarks, sorted alphabetically', () => { - const store = newStore(); - store.addBookmark(makeBookmark({ id: 'a', uri: 'file:///repo/src/a.ts', tags: ['review', 'auth'] })); - store.addBookmark(makeBookmark({ id: 'b', uri: 'file:///repo/src/b.ts', tags: ['auth', 'todo'] })); - store.addBookmark(makeBookmark({ id: 'c', uri: 'file:///repo/src/c.ts' })); - - expect(store.getAllTags()).toEqual(['auth', 'review', 'todo']); - }); + it('starts empty', () => { + const store = newStore(); + expect(store.getAllBookmarks()).toEqual([]); + expect(store.getAllFolders()).toEqual([]); + }); + + it('adds a bookmark', () => { + const store = newStore(); + store.addBookmark(makeBookmark()); + expect(store.getAllBookmarks()).toHaveLength(1); + }); + + it('does not add a duplicate bookmark for the same uri', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a' })); + store.addBookmark(makeBookmark({ id: 'b' })); + const all = store.getAllBookmarks(); + expect(all).toHaveLength(1); + expect(all[0].id).toBe('a'); + }); + + it('removes a bookmark by id', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a' })); + store.removeBookmark('a'); + expect(store.getAllBookmarks()).toEqual([]); + }); + + it('moves a bookmark to a folder', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a', folderId: null })); + store.moveBookmarkToFolder('a', 'folder-1'); + expect(store.getAllBookmarks()[0].folderId).toBe('folder-1'); + }); + + it('renames a bookmark label', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a', label: 'a.ts' })); + store.renameBookmark('a', 'My File'); + expect(store.getAllBookmarks()[0].label).toBe('My File'); + }); + + it('sets tags on a bookmark', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a' })); + store.setBookmarkTags('a', ['auth', 'review']); + expect(store.getAllBookmarks()[0].tags).toEqual(['auth', 'review']); + }); + + it('collects every distinct tag across bookmarks, sorted alphabetically', () => { + const store = newStore(); + store.addBookmark( + makeBookmark({ id: 'a', uri: 'file:///repo/src/a.ts', tags: ['review', 'auth'] }), + ); + store.addBookmark( + makeBookmark({ id: 'b', uri: 'file:///repo/src/b.ts', tags: ['auth', 'todo'] }), + ); + store.addBookmark(makeBookmark({ id: 'c', uri: 'file:///repo/src/c.ts' })); + + expect(store.getAllTags()).toEqual(['auth', 'review', 'todo']); + }); + + it('assigns sequential order to the named bookmarks and leaves others untouched', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a', uri: 'file:///repo/src/a.ts' })); + store.addBookmark(makeBookmark({ id: 'b', uri: 'file:///repo/src/b.ts' })); + store.addBookmark(makeBookmark({ id: 'c', uri: 'file:///repo/src/c.ts' })); + + store.reorderBookmarks(['c', 'a']); + + const byId = new Map(store.getAllBookmarks().map((b) => [b.id, b])); + expect(byId.get('c')?.order).toBe(0); + expect(byId.get('a')?.order).toBe(1); + expect(byId.get('b')?.order).toBeUndefined(); + }); + + it('assigns sequential order to the named folders and leaves others untouched', () => { + const store = newStore(); + const first = store.createFolder('First'); + const second = store.createFolder('Second'); + + store.reorderFolders([second.id, first.id]); + + const byId = new Map(store.getAllFolders().map((f) => [f.id, f])); + expect(byId.get(second.id)?.order).toBe(0); + expect(byId.get(first.id)?.order).toBe(1); + }); + + it('creates, renames, and deletes a folder', () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + expect(store.getAllFolders()).toEqual([folder]); + + store.renameFolder(folder.id, 'API'); + expect(store.getAllFolders()[0].name).toBe('API'); + + store.deleteFolder(folder.id); + expect(store.getAllFolders()).toEqual([]); + }); + + it('unsets folderId on bookmarks when their folder is deleted', () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + store.addBookmark(makeBookmark({ id: 'a', folderId: folder.id })); + + store.deleteFolder(folder.id); + + expect(store.getAllBookmarks()[0].folderId).toBeNull(); + }); + + it('creates a folder with a parent when given one', () => { + const store = newStore(); + const parent = store.createFolder('Backend'); + const child = store.createFolder('Auth Service', parent.id); + expect(child.parentId).toBe(parent.id); + }); + + it('defaults a folder to root (parentId null) when no parent is given', () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + expect(folder.parentId).toBeNull(); + }); + + it('moves a folder to a new parent', () => { + const store = newStore(); + const a = store.createFolder('A'); + const b = store.createFolder('B'); + + store.moveFolderToParent(b.id, a.id); + + expect(store.getAllFolders().find((f) => f.id === b.id)?.parentId).toBe(a.id); + }); + + it('promotes child folders to root when their parent is deleted', () => { + const store = newStore(); + const parent = store.createFolder('Backend'); + const child = store.createFolder('Auth Service', parent.id); + + store.deleteFolder(parent.id); + + expect(store.getAllFolders().find((f) => f.id === child.id)?.parentId).toBeNull(); + }); + + it('fires onDidChange when bookmarks or folders change', () => { + const store = newStore(); + let fireCount = 0; + store.onDidChange(() => fireCount++); + + store.addBookmark(makeBookmark()); + store.createFolder('Backend'); + + expect(fireCount).toBe(2); + }); +}); - it('assigns sequential order to the named bookmarks and leaves others untouched', () => { - const store = newStore(); - store.addBookmark(makeBookmark({ id: 'a', uri: 'file:///repo/src/a.ts' })); - store.addBookmark(makeBookmark({ id: 'b', uri: 'file:///repo/src/b.ts' })); - store.addBookmark(makeBookmark({ id: 'c', uri: 'file:///repo/src/c.ts' })); +describe('describeBookmark', () => { + const bookmark = makeBookmark({ workspaceFolderName: 'repo-a', relativePath: 'src/a.ts' }); - store.reorderBookmarks(['c', 'a']); + it('is just the relative path in a single-root workspace with no folder', () => { + expect(describeBookmark(bookmark, false, null)).toEqual(['src/a.ts']); + }); - const byId = new Map(store.getAllBookmarks().map(b => [b.id, b])); - expect(byId.get('c')?.order).toBe(0); - expect(byId.get('a')?.order).toBe(1); - expect(byId.get('b')?.order).toBeUndefined(); - }); + it('prefixes the workspace folder name in a multi-root workspace', () => { + expect(describeBookmark(bookmark, true, null)).toEqual(['repo-a', 'src/a.ts']); + }); - it('assigns sequential order to the named folders and leaves others untouched', () => { - const store = newStore(); - const first = store.createFolder('First'); - const second = store.createFolder('Second'); + it('prefixes the bookmark folder name when set', () => { + expect(describeBookmark(bookmark, false, 'Backend')).toEqual(['Backend', 'src/a.ts']); + }); - store.reorderFolders([second.id, first.id]); + it('shows folder name and workspace folder name together', () => { + expect(describeBookmark(bookmark, true, 'Backend')).toEqual(['Backend', 'repo-a', 'src/a.ts']); + }); - const byId = new Map(store.getAllFolders().map(f => [f.id, f])); - expect(byId.get(second.id)?.order).toBe(0); - expect(byId.get(first.id)?.order).toBe(1); + it('appends hashtag-prefixed tags when present', () => { + const tagged = makeBookmark({ + workspaceFolderName: 'repo-a', + relativePath: 'src/a.ts', + tags: ['auth', 'review'], }); - - it('creates, renames, and deletes a folder', () => { - const store = newStore(); - const folder = store.createFolder('Backend'); - expect(store.getAllFolders()).toEqual([folder]); - - store.renameFolder(folder.id, 'API'); - expect(store.getAllFolders()[0].name).toBe('API'); - - store.deleteFolder(folder.id); - expect(store.getAllFolders()).toEqual([]); + expect(describeBookmark(tagged, false, null)).toEqual(['src/a.ts', '#auth #review']); + }); + + it('omits the tags segment when tags is an empty array', () => { + const tagged = makeBookmark({ + workspaceFolderName: 'repo-a', + relativePath: 'src/a.ts', + tags: [], }); + expect(describeBookmark(tagged, false, null)).toEqual(['src/a.ts']); + }); +}); - it('unsets folderId on bookmarks when their folder is deleted', () => { - const store = newStore(); - const folder = store.createFolder('Backend'); - store.addBookmark(makeBookmark({ id: 'a', folderId: folder.id })); - - store.deleteFolder(folder.id); +describe('matchesSearchFilter', () => { + const bookmark = makeBookmark({ + label: 'auth-service.ts', + relativePath: 'src/auth/service.ts', + workspaceFolderName: 'backend-repo', + }); + + it('matches (case-insensitively) against the label', () => { + expect(matchesSearchFilter(bookmark, 'AUTH-SERVICE')).toBe(true); + }); + + it('matches against the relative path', () => { + expect(matchesSearchFilter(bookmark, 'src/auth')).toBe(true); + }); + + it('matches against the workspace folder name', () => { + expect(matchesSearchFilter(bookmark, 'backend')).toBe(true); + }); + + it('returns false when nothing matches', () => { + expect(matchesSearchFilter(bookmark, 'frontend')).toBe(false); + }); + + it('treats a blank query as matching everything', () => { + expect(matchesSearchFilter(bookmark, ' ')).toBe(true); + }); +}); - expect(store.getAllBookmarks()[0].folderId).toBeNull(); - }); +function makeFolder(id: string, name: string, parentId: string | null = null): BookmarkFolder { + return { id, name, createdAt: 0, parentId }; +} - it('fires onDidChange when bookmarks or folders change', () => { - const store = newStore(); - let fireCount = 0; - store.onDidChange(() => fireCount++); +describe('folderSubtreeHeight', () => { + it('is 0 for a folder with no children', () => { + const folders = [makeFolder('a', 'A')]; + expect(folderSubtreeHeight(folders, 'a')).toBe(0); + }); - store.addBookmark(makeBookmark()); - store.createFolder('Backend'); + it('is the depth of the deepest descendant chain', () => { + const folders = [makeFolder('a', 'A'), makeFolder('b', 'B', 'a'), makeFolder('c', 'C', 'b')]; + expect(folderSubtreeHeight(folders, 'a')).toBe(2); + }); +}); - expect(fireCount).toBe(2); - }); +describe('isDescendantFolder', () => { + const folders = [ + makeFolder('a', 'A'), + makeFolder('b', 'B', 'a'), + makeFolder('c', 'C', 'b'), + makeFolder('sibling', 'Sibling'), + ]; + + it('is true for the folder itself', () => { + expect(isDescendantFolder(folders, 'a', 'a')).toBe(true); + }); + + it('is true for a direct child', () => { + expect(isDescendantFolder(folders, 'a', 'b')).toBe(true); + }); + + it('is true for a deeper descendant', () => { + expect(isDescendantFolder(folders, 'a', 'c')).toBe(true); + }); + + it('is false for an unrelated folder', () => { + expect(isDescendantFolder(folders, 'a', 'sibling')).toBe(false); + }); }); -describe('describeBookmark', () => { - const bookmark = makeBookmark({ workspaceFolderName: 'repo-a', relativePath: 'src/a.ts' }); +describe('canNestUnder', () => { + it('allows nesting at the root', () => { + expect(canNestUnder([], null)).toBe(true); + }); + + it('allows nesting up to MAX_FOLDER_DEPTH', () => { + const folders = [makeFolder('l1', 'L1'), makeFolder('l2', 'L2', 'l1')]; + expect(canNestUnder(folders, 'l2')).toBe(true); + }); + + it('refuses nesting that would exceed MAX_FOLDER_DEPTH', () => { + const folders = [ + makeFolder('l1', 'L1'), + makeFolder('l2', 'L2', 'l1'), + makeFolder('l3', 'L3', 'l2'), + ]; + expect(canNestUnder(folders, 'l3')).toBe(false); + }); + + it('accounts for the height of a subtree being moved', () => { + const folders = [makeFolder('l1', 'L1'), makeFolder('l2', 'L2', 'l1')]; + // l2 has no children (height 0) so nesting under l1 (depth 1) is fine... + expect(canNestUnder(folders, 'l1', 0)).toBe(true); + // ...but nesting a subtree of height 1 under l2 (depth 2) would reach depth 4. + expect(canNestUnder(folders, 'l2', 1)).toBe(false); + }); + + it('confirms MAX_FOLDER_DEPTH is 3', () => { + expect(MAX_FOLDER_DEPTH).toBe(3); + }); +}); - it('is just the relative path in a single-root workspace with no folder', () => { - expect(describeBookmark(bookmark, false, null)).toEqual(['src/a.ts']); - }); +describe('eligibleParentFolders', () => { + it('excludes folders that are already at max depth', () => { + const store = newStore(); + const l1 = store.createFolder('L1'); + const l2 = store.createFolder('L2', l1.id); + store.createFolder('L3', l2.id); - it('prefixes the workspace folder name in a multi-root workspace', () => { - expect(describeBookmark(bookmark, true, null)).toEqual(['repo-a', 'src/a.ts']); - }); + const eligible = eligibleParentFolders(store); - it('prefixes the bookmark folder name when set', () => { - expect(describeBookmark(bookmark, false, 'Backend')).toEqual(['Backend', 'src/a.ts']); - }); + expect(eligible.map((f) => f.id)).toEqual([l1.id, l2.id]); + }); - it('shows folder name and workspace folder name together', () => { - expect(describeBookmark(bookmark, true, 'Backend')).toEqual(['Backend', 'repo-a', 'src/a.ts']); - }); + it('excludes the folder itself and its descendants when reparenting', () => { + const store = newStore(); + const a = store.createFolder('A'); + store.createFolder('B', a.id); + const sibling = store.createFolder('Sibling'); - it('appends hashtag-prefixed tags when present', () => { - const tagged = makeBookmark({ workspaceFolderName: 'repo-a', relativePath: 'src/a.ts', tags: ['auth', 'review'] }); - expect(describeBookmark(tagged, false, null)).toEqual(['src/a.ts', '#auth #review']); - }); + const eligible = eligibleParentFolders(store, { excludeFolderId: a.id }); - it('omits the tags segment when tags is an empty array', () => { - const tagged = makeBookmark({ workspaceFolderName: 'repo-a', relativePath: 'src/a.ts', tags: [] }); - expect(describeBookmark(tagged, false, null)).toEqual(['src/a.ts']); - }); + expect(eligible.map((f) => f.id)).toEqual([sibling.id]); + }); }); -describe('matchesSearchFilter', () => { - const bookmark = makeBookmark({ label: 'auth-service.ts', relativePath: 'src/auth/service.ts', workspaceFolderName: 'backend-repo' }); - - it('matches (case-insensitively) against the label', () => { - expect(matchesSearchFilter(bookmark, 'AUTH-SERVICE')).toBe(true); - }); +describe('folderBreadcrumb', () => { + it('is just the name for a root-level folder', () => { + const folders = [makeFolder('a', 'Backend')]; + expect(folderBreadcrumb(folders, 'a')).toBe('Backend'); + }); - it('matches against the relative path', () => { - expect(matchesSearchFilter(bookmark, 'src/auth')).toBe(true); - }); + it('joins ancestors with a chevron for a nested folder', () => { + const folders = [makeFolder('a', 'Backend'), makeFolder('b', 'Auth Service', 'a')]; + expect(folderBreadcrumb(folders, 'b')).toBe('Backend › Auth Service'); + }); - it('matches against the workspace folder name', () => { - expect(matchesSearchFilter(bookmark, 'backend')).toBe(true); - }); - - it('returns false when nothing matches', () => { - expect(matchesSearchFilter(bookmark, 'frontend')).toBe(false); - }); - - it('treats a blank query as matching everything', () => { - expect(matchesSearchFilter(bookmark, ' ')).toBe(true); - }); + it('stops cleanly at a dangling (missing) folder id', () => { + expect(folderBreadcrumb([], 'missing')).toBe(''); + }); }); diff --git a/test/commands.test.ts b/test/commands.test.ts index 4be4487..c407f47 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -1,531 +1,677 @@ import { describe, it, expect, vi } from 'vitest'; import { - BookmarkStore, - addActiveFileBookmark, - addBookmarksForUris, - addBookmarksToFolder, - createFolder, - deleteFolder, - editBookmarkTags, - filterBookmarks, - filterByTag, - moveToFolder, - openAllInFolder, - openBookmark, - pickFolder, - renameBookmark, - renameFolder, - type Bookmark, - type BookmarkFolder + BookmarkStore, + addActiveFileBookmark, + addBookmarksForUris, + addBookmarksToFolder, + createFolder, + createSubfolder, + deleteFolder, + editBookmarkTags, + filterBookmarks, + filterByTag, + moveFolderToParent, + moveToFolder, + openAllInFolder, + openBookmark, + pickFolder, + pickParentFolder, + renameBookmark, + renameFolder, + type Bookmark, + type BookmarkFolder, } from '../extension'; import { createFakeContext } from './fakeContext'; import { Uri, window, workspace } from './vscode-mock'; import type { InputBox } from './vscode-mock'; function newStore() { - return new BookmarkStore(createFakeContext() as any); + return new BookmarkStore(createFakeContext() as any); } function makeBookmark(overrides: Partial = {}): Bookmark { - return { - id: 'id-1', - uri: 'file:///repo/src/a.ts', - label: 'a.ts', - relativePath: 'src/a.ts', - workspaceFolderName: 'repo', - folderId: null, - createdAt: 0, - ...overrides - }; + return { + id: 'id-1', + uri: 'file:///repo/src/a.ts', + label: 'a.ts', + relativePath: 'src/a.ts', + workspaceFolderName: 'repo', + folderId: null, + createdAt: 0, + ...overrides, + }; } function makeFolder(overrides: Partial = {}): BookmarkFolder { - return { id: 'folder-1', name: 'Backend', createdAt: 0, ...overrides }; + return { id: 'folder-1', name: 'Backend', createdAt: 0, ...overrides }; } describe('addActiveFileBookmark', () => { - it('bookmarks the active editor document', () => { - const store = newStore(); - workspace.getWorkspaceFolder.mockReturnValue(undefined); - window.activeTextEditor = { document: { uri: Uri.file('/repo/a.ts') } }; + it('bookmarks the active editor document', () => { + const store = newStore(); + workspace.getWorkspaceFolder.mockReturnValue(undefined); + window.activeTextEditor = { document: { uri: Uri.file('/repo/a.ts') } }; - addActiveFileBookmark(store); + addActiveFileBookmark(store); - expect(store.getAllBookmarks()).toHaveLength(1); - }); + expect(store.getAllBookmarks()).toHaveLength(1); + }); - it('warns and does nothing when no file is open', () => { - const store = newStore(); - window.activeTextEditor = undefined; + it('warns and does nothing when no file is open', () => { + const store = newStore(); + window.activeTextEditor = undefined; - addActiveFileBookmark(store); + addActiveFileBookmark(store); - expect(window.showWarningMessage).toHaveBeenCalledOnce(); - expect(store.getAllBookmarks()).toHaveLength(0); - }); + expect(window.showWarningMessage).toHaveBeenCalledOnce(); + expect(store.getAllBookmarks()).toHaveLength(0); + }); }); describe('addBookmarksForUris', () => { - it('bookmarks every uri in a multi-select', () => { - const store = newStore(); - workspace.getWorkspaceFolder.mockReturnValue(undefined); + it('bookmarks every uri in a multi-select', () => { + const store = newStore(); + workspace.getWorkspaceFolder.mockReturnValue(undefined); - addBookmarksForUris(store, undefined, [Uri.file('/repo/a.ts') as any, Uri.file('/repo/b.ts') as any]); + addBookmarksForUris(store, undefined, [ + Uri.file('/repo/a.ts') as any, + Uri.file('/repo/b.ts') as any, + ]); - expect(store.getAllBookmarks()).toHaveLength(2); - }); + expect(store.getAllBookmarks()).toHaveLength(2); + }); - it('falls back to the single uri when uris is empty', () => { - const store = newStore(); - workspace.getWorkspaceFolder.mockReturnValue(undefined); + it('falls back to the single uri when uris is empty', () => { + const store = newStore(); + workspace.getWorkspaceFolder.mockReturnValue(undefined); - addBookmarksForUris(store, Uri.file('/repo/a.ts') as any, undefined); + addBookmarksForUris(store, Uri.file('/repo/a.ts') as any, undefined); - expect(store.getAllBookmarks()).toHaveLength(1); - }); + expect(store.getAllBookmarks()).toHaveLength(1); + }); - it('does nothing when neither uri nor uris is provided', () => { - const store = newStore(); + it('does nothing when neither uri nor uris is provided', () => { + const store = newStore(); - addBookmarksForUris(store, undefined, undefined); + addBookmarksForUris(store, undefined, undefined); - expect(store.getAllBookmarks()).toHaveLength(0); - }); + expect(store.getAllBookmarks()).toHaveLength(0); + }); }); describe('openBookmark', () => { - it('opens the document in a non-preview editor', async () => { - const document = {}; - workspace.openTextDocument.mockResolvedValue(document); + it('opens the document in a non-preview editor', async () => { + const document = {}; + workspace.openTextDocument.mockResolvedValue(document); - await openBookmark(makeBookmark()); + await openBookmark(makeBookmark()); - expect(workspace.openTextDocument).toHaveBeenCalled(); - expect(window.showTextDocument).toHaveBeenCalledWith(document, { preview: false }); - }); + expect(workspace.openTextDocument).toHaveBeenCalled(); + expect(window.showTextDocument).toHaveBeenCalledWith(document, { preview: false }); + }); - it('shows an error when the file cannot be opened', async () => { - workspace.openTextDocument.mockRejectedValue(new Error('not found')); + it('shows an error when the file cannot be opened', async () => { + workspace.openTextDocument.mockRejectedValue(new Error('not found')); - await openBookmark(makeBookmark()); + await openBookmark(makeBookmark()); - expect(window.showErrorMessage).toHaveBeenCalledOnce(); - }); + expect(window.showErrorMessage).toHaveBeenCalledOnce(); + }); }); describe('openAllInFolder', () => { - it('opens every bookmark in the folder', async () => { - const document = {}; - workspace.openTextDocument.mockResolvedValue(document); - const item = { folder: makeFolder(), bookmarks: [makeBookmark({ id: 'a' }), makeBookmark({ id: 'b' })] }; + it('opens every bookmark in the folder', async () => { + const document = {}; + workspace.openTextDocument.mockResolvedValue(document); + const item = { + folder: makeFolder(), + bookmarks: [makeBookmark({ id: 'a' }), makeBookmark({ id: 'b' })], + }; - await openAllInFolder(newStore(), item as any); + await openAllInFolder(newStore(), item as any); - expect(workspace.openTextDocument).toHaveBeenCalledTimes(2); - }); + expect(workspace.openTextDocument).toHaveBeenCalledTimes(2); + }); - it('shows an info message instead of opening when the folder is empty', async () => { - const item = { folder: makeFolder(), bookmarks: [] }; + it('shows an info message instead of opening when the folder is empty', async () => { + const item = { folder: makeFolder(), bookmarks: [] }; - await openAllInFolder(newStore(), item as any); + await openAllInFolder(newStore(), item as any); - expect(window.showInformationMessage).toHaveBeenCalledOnce(); - expect(workspace.openTextDocument).not.toHaveBeenCalled(); - }); + expect(window.showInformationMessage).toHaveBeenCalledOnce(); + expect(workspace.openTextDocument).not.toHaveBeenCalled(); + }); }); describe('createFolder', () => { - it('creates a folder from trimmed input', async () => { - const store = newStore(); - window.showInputBox.mockResolvedValue(' Backend '); + it('creates a folder from trimmed input', async () => { + const store = newStore(); + window.showInputBox.mockResolvedValue(' Backend '); - await createFolder(store); + await createFolder(store); - expect(store.getAllFolders()).toHaveLength(1); - expect(store.getAllFolders()[0].name).toBe('Backend'); + expect(store.getAllFolders()).toHaveLength(1); + expect(store.getAllFolders()[0].name).toBe('Backend'); - const validateInput = window.showInputBox.mock.calls[0][0].validateInput; - expect(validateInput(' ')).toMatch(/cannot be empty/); - expect(validateInput('Backend')).toBeUndefined(); - }); + const validateInput = window.showInputBox.mock.calls[0][0].validateInput; + expect(validateInput(' ')).toMatch(/cannot be empty/); + expect(validateInput('Backend')).toBeUndefined(); + }); - it('does nothing when the input is cancelled', async () => { - const store = newStore(); - window.showInputBox.mockResolvedValue(undefined); + it('does nothing when the input is cancelled', async () => { + const store = newStore(); + window.showInputBox.mockResolvedValue(undefined); - await createFolder(store); + await createFolder(store); - expect(store.getAllFolders()).toHaveLength(0); - }); + expect(store.getAllFolders()).toHaveLength(0); + }); }); describe('renameFolder', () => { - it('renames the folder from trimmed input', async () => { - const store = newStore(); - const folder = store.createFolder('Backend'); - window.showInputBox.mockResolvedValue(' API '); + it('renames the folder from trimmed input', async () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + window.showInputBox.mockResolvedValue(' API '); - await renameFolder(store, { folder } as any); + await renameFolder(store, { folder } as any); - expect(store.getAllFolders()[0].name).toBe('API'); + expect(store.getAllFolders()[0].name).toBe('API'); - const validateInput = window.showInputBox.mock.calls[0][0].validateInput; - expect(validateInput(' ')).toMatch(/cannot be empty/); - expect(validateInput('API')).toBeUndefined(); - }); + const validateInput = window.showInputBox.mock.calls[0][0].validateInput; + expect(validateInput(' ')).toMatch(/cannot be empty/); + expect(validateInput('API')).toBeUndefined(); + }); - it('does nothing when the input is cancelled', async () => { - const store = newStore(); - const folder = store.createFolder('Backend'); - window.showInputBox.mockResolvedValue(undefined); + it('does nothing when the input is cancelled', async () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + window.showInputBox.mockResolvedValue(undefined); - await renameFolder(store, { folder } as any); + await renameFolder(store, { folder } as any); - expect(store.getAllFolders()[0].name).toBe('Backend'); - }); + expect(store.getAllFolders()[0].name).toBe('Backend'); + }); }); describe('renameBookmark', () => { - it('renames the bookmark from trimmed input', async () => { - const store = newStore(); - const bookmark = makeBookmark(); - store.addBookmark(bookmark); - window.showInputBox.mockResolvedValue(' My Bookmark '); + it('renames the bookmark from trimmed input', async () => { + const store = newStore(); + const bookmark = makeBookmark(); + store.addBookmark(bookmark); + window.showInputBox.mockResolvedValue(' My Bookmark '); - await renameBookmark(store, { bookmark } as any); + await renameBookmark(store, { bookmark } as any); - expect(store.getAllBookmarks()[0].label).toBe('My Bookmark'); + expect(store.getAllBookmarks()[0].label).toBe('My Bookmark'); - const validateInput = window.showInputBox.mock.calls[0][0].validateInput; - expect(validateInput(' ')).toMatch(/cannot be empty/); - expect(validateInput('My Bookmark')).toBeUndefined(); - }); + const validateInput = window.showInputBox.mock.calls[0][0].validateInput; + expect(validateInput(' ')).toMatch(/cannot be empty/); + expect(validateInput('My Bookmark')).toBeUndefined(); + }); - it('does nothing when the input is cancelled', async () => { - const store = newStore(); - const bookmark = makeBookmark(); - store.addBookmark(bookmark); - window.showInputBox.mockResolvedValue(undefined); + it('does nothing when the input is cancelled', async () => { + const store = newStore(); + const bookmark = makeBookmark(); + store.addBookmark(bookmark); + window.showInputBox.mockResolvedValue(undefined); - await renameBookmark(store, { bookmark } as any); + await renameBookmark(store, { bookmark } as any); - expect(store.getAllBookmarks()[0].label).toBe('a.ts'); - }); + expect(store.getAllBookmarks()[0].label).toBe('a.ts'); + }); }); describe('editBookmarkTags', () => { - it('sets tags from the picked items, pre-checking the bookmark\'s current tags', async () => { - const store = newStore(); - const bookmark = makeBookmark({ tags: ['auth'] }); - store.addBookmark(bookmark); - store.addBookmark(makeBookmark({ id: 'other', uri: 'file:///repo/src/other.ts', tags: ['review'] })); - window.showQuickPick.mockResolvedValue([{ label: 'auth', picked: true }, { label: 'review' }]); - - await editBookmarkTags(store, { bookmark } as any); - - expect(store.getAllBookmarks().find(b => b.id === bookmark.id)?.tags).toEqual(['auth', 'review']); - const items = window.showQuickPick.mock.calls[0][0]; - expect(items).toEqual([ - { label: 'auth', picked: true }, - { label: 'review', picked: false }, - { label: '$(add) Add New Tag...' } - ]); - expect(window.showQuickPick.mock.calls[0][1]).toMatchObject({ canPickMany: true }); - }); - - it('does nothing when the quick-pick is cancelled', async () => { - const store = newStore(); - const bookmark = makeBookmark({ tags: ['auth'] }); - store.addBookmark(bookmark); - window.showQuickPick.mockResolvedValue(undefined); - - await editBookmarkTags(store, { bookmark } as any); - - expect(store.getAllBookmarks()[0].tags).toEqual(['auth']); - }); - - it('prompts for and adds a new tag when "Add New Tag..." is picked', async () => { - const store = newStore(); - const bookmark = makeBookmark({ tags: [] }); - store.addBookmark(bookmark); - window.showQuickPick.mockResolvedValue([{ label: '$(add) Add New Tag...' }]); - window.showInputBox.mockResolvedValue(' urgent '); - - await editBookmarkTags(store, { bookmark } as any); - - expect(store.getAllBookmarks()[0].tags).toEqual(['urgent']); - const validateInput = window.showInputBox.mock.calls[0][0].validateInput; - expect(validateInput(' ')).toMatch(/cannot be empty/); - expect(validateInput('urgent')).toBeUndefined(); - }); - - it('drops "Add New Tag..." from the result when its input box is cancelled', async () => { - const store = newStore(); - const bookmark = makeBookmark({ tags: [] }); - store.addBookmark(bookmark); - window.showQuickPick.mockResolvedValue([{ label: 'auth' }, { label: '$(add) Add New Tag...' }]); - window.showInputBox.mockResolvedValue(undefined); - - await editBookmarkTags(store, { bookmark } as any); - - expect(store.getAllBookmarks()[0].tags).toEqual(['auth']); - }); - - it('deduplicates tags picked more than once', async () => { - const store = newStore(); - const bookmark = makeBookmark({ tags: ['auth'] }); - store.addBookmark(bookmark); - window.showQuickPick.mockResolvedValue([{ label: 'auth' }, { label: '$(add) Add New Tag...' }]); - window.showInputBox.mockResolvedValue('auth'); - - await editBookmarkTags(store, { bookmark } as any); - - expect(store.getAllBookmarks()[0].tags).toEqual(['auth']); - }); + it("sets tags from the picked items, pre-checking the bookmark's current tags", async () => { + const store = newStore(); + const bookmark = makeBookmark({ tags: ['auth'] }); + store.addBookmark(bookmark); + store.addBookmark( + makeBookmark({ id: 'other', uri: 'file:///repo/src/other.ts', tags: ['review'] }), + ); + window.showQuickPick.mockResolvedValue([{ label: 'auth', picked: true }, { label: 'review' }]); + + await editBookmarkTags(store, { bookmark } as any); + + expect(store.getAllBookmarks().find((b) => b.id === bookmark.id)?.tags).toEqual([ + 'auth', + 'review', + ]); + const items = window.showQuickPick.mock.calls[0][0]; + expect(items).toEqual([ + { label: 'auth', picked: true }, + { label: 'review', picked: false }, + { label: '$(add) Add New Tag...' }, + ]); + expect(window.showQuickPick.mock.calls[0][1]).toMatchObject({ canPickMany: true }); + }); + + it('does nothing when the quick-pick is cancelled', async () => { + const store = newStore(); + const bookmark = makeBookmark({ tags: ['auth'] }); + store.addBookmark(bookmark); + window.showQuickPick.mockResolvedValue(undefined); + + await editBookmarkTags(store, { bookmark } as any); + + expect(store.getAllBookmarks()[0].tags).toEqual(['auth']); + }); + + it('prompts for and adds a new tag when "Add New Tag..." is picked', async () => { + const store = newStore(); + const bookmark = makeBookmark({ tags: [] }); + store.addBookmark(bookmark); + window.showQuickPick.mockResolvedValue([{ label: '$(add) Add New Tag...' }]); + window.showInputBox.mockResolvedValue(' urgent '); + + await editBookmarkTags(store, { bookmark } as any); + + expect(store.getAllBookmarks()[0].tags).toEqual(['urgent']); + const validateInput = window.showInputBox.mock.calls[0][0].validateInput; + expect(validateInput(' ')).toMatch(/cannot be empty/); + expect(validateInput('urgent')).toBeUndefined(); + }); + + it('drops "Add New Tag..." from the result when its input box is cancelled', async () => { + const store = newStore(); + const bookmark = makeBookmark({ tags: [] }); + store.addBookmark(bookmark); + window.showQuickPick.mockResolvedValue([{ label: 'auth' }, { label: '$(add) Add New Tag...' }]); + window.showInputBox.mockResolvedValue(undefined); + + await editBookmarkTags(store, { bookmark } as any); + + expect(store.getAllBookmarks()[0].tags).toEqual(['auth']); + }); + + it('deduplicates tags picked more than once', async () => { + const store = newStore(); + const bookmark = makeBookmark({ tags: ['auth'] }); + store.addBookmark(bookmark); + window.showQuickPick.mockResolvedValue([{ label: 'auth' }, { label: '$(add) Add New Tag...' }]); + window.showInputBox.mockResolvedValue('auth'); + + await editBookmarkTags(store, { bookmark } as any); + + expect(store.getAllBookmarks()[0].tags).toEqual(['auth']); + }); }); describe('filterByTag', () => { - function fakeProvider(overrides: { tagFilter?: string | null } = {}) { - return { - tagFilter: overrides.tagFilter ?? null, - getTagFilter(this: { tagFilter: string | null }) { - return this.tagFilter; - }, - setTagFilter: vi.fn() - }; - } - - it('shows an info message and skips the quick-pick when there are no tags yet', async () => { - const store = newStore(); - const provider = fakeProvider(); - - await filterByTag(store, provider as any); - - expect(window.showInformationMessage).toHaveBeenCalledOnce(); - expect(window.showQuickPick).not.toHaveBeenCalled(); - expect(provider.setTagFilter).not.toHaveBeenCalled(); - }); - - it('sets the tag filter to the picked tag', async () => { - const store = newStore(); - store.addBookmark(makeBookmark({ tags: ['auth'] })); - const provider = fakeProvider(); - window.showQuickPick.mockResolvedValue('auth'); - - await filterByTag(store, provider as any); - - expect(provider.setTagFilter).toHaveBeenCalledWith('auth'); - expect(window.showQuickPick.mock.calls[0][0]).toEqual(['auth']); - }); - - it('offers a "Clear Filter" pick when a filter is already active', async () => { - const store = newStore(); - store.addBookmark(makeBookmark({ tags: ['auth'] })); - const provider = fakeProvider({ tagFilter: 'auth' }); - window.showQuickPick.mockResolvedValue('$(clear-all) Clear Filter'); - - await filterByTag(store, provider as any); - - expect(window.showQuickPick.mock.calls[0][0]).toEqual(['$(clear-all) Clear Filter', 'auth']); - expect(provider.setTagFilter).toHaveBeenCalledWith(null); - }); - - it('does nothing when the quick-pick is cancelled', async () => { - const store = newStore(); - store.addBookmark(makeBookmark({ tags: ['auth'] })); - const provider = fakeProvider(); - window.showQuickPick.mockResolvedValue(undefined); - - await filterByTag(store, provider as any); - - expect(provider.setTagFilter).not.toHaveBeenCalled(); - }); + function fakeProvider(overrides: { tagFilter?: string | null } = {}) { + return { + tagFilter: overrides.tagFilter ?? null, + getTagFilter(this: { tagFilter: string | null }) { + return this.tagFilter; + }, + setTagFilter: vi.fn(), + }; + } + + it('shows an info message and skips the quick-pick when there are no tags yet', async () => { + const store = newStore(); + const provider = fakeProvider(); + + await filterByTag(store, provider as any); + + expect(window.showInformationMessage).toHaveBeenCalledOnce(); + expect(window.showQuickPick).not.toHaveBeenCalled(); + expect(provider.setTagFilter).not.toHaveBeenCalled(); + }); + + it('sets the tag filter to the picked tag', async () => { + const store = newStore(); + store.addBookmark(makeBookmark({ tags: ['auth'] })); + const provider = fakeProvider(); + window.showQuickPick.mockResolvedValue('auth'); + + await filterByTag(store, provider as any); + + expect(provider.setTagFilter).toHaveBeenCalledWith('auth'); + expect(window.showQuickPick.mock.calls[0][0]).toEqual(['auth']); + }); + + it('offers a "Clear Filter" pick when a filter is already active', async () => { + const store = newStore(); + store.addBookmark(makeBookmark({ tags: ['auth'] })); + const provider = fakeProvider({ tagFilter: 'auth' }); + window.showQuickPick.mockResolvedValue('$(clear-all) Clear Filter'); + + await filterByTag(store, provider as any); + + expect(window.showQuickPick.mock.calls[0][0]).toEqual(['$(clear-all) Clear Filter', 'auth']); + expect(provider.setTagFilter).toHaveBeenCalledWith(null); + }); + + it('does nothing when the quick-pick is cancelled', async () => { + const store = newStore(); + store.addBookmark(makeBookmark({ tags: ['auth'] })); + const provider = fakeProvider(); + window.showQuickPick.mockResolvedValue(undefined); + + await filterByTag(store, provider as any); + + expect(provider.setTagFilter).not.toHaveBeenCalled(); + }); }); describe('filterBookmarks', () => { - function fakeProvider(overrides: { searchFilter?: string | null } = {}) { - return { - searchFilter: overrides.searchFilter ?? null, - getSearchFilter(this: { searchFilter: string | null }) { - return this.searchFilter; - }, - setSearchFilter: vi.fn() - }; - } + function fakeProvider(overrides: { searchFilter?: string | null } = {}) { + return { + searchFilter: overrides.searchFilter ?? null, + getSearchFilter(this: { searchFilter: string | null }) { + return this.searchFilter; + }, + setSearchFilter: vi.fn(), + }; + } - it('opens the input box pre-filled with the current filter', () => { - const provider = fakeProvider({ searchFilter: 'auth' }); + it('opens the input box pre-filled with the current filter', () => { + const provider = fakeProvider({ searchFilter: 'auth' }); - const inputBox = filterBookmarks(provider as any) as unknown as InputBox; + const inputBox = filterBookmarks(provider as any) as unknown as InputBox; - expect(inputBox.value).toBe('auth'); - expect(inputBox.visible).toBe(true); - }); + expect(inputBox.value).toBe('auth'); + expect(inputBox.visible).toBe(true); + }); - it('updates the filter live as the value changes', () => { - const provider = fakeProvider(); + it('updates the filter live as the value changes', () => { + const provider = fakeProvider(); - const inputBox = filterBookmarks(provider as any) as unknown as InputBox; - inputBox.triggerChangeValue('auth'); + const inputBox = filterBookmarks(provider as any) as unknown as InputBox; + inputBox.triggerChangeValue('auth'); - expect(provider.setSearchFilter).toHaveBeenCalledWith('auth'); - }); + expect(provider.setSearchFilter).toHaveBeenCalledWith('auth'); + }); - it('keeps the filter and closes on accept', () => { - const provider = fakeProvider(); + it('keeps the filter and closes on accept', () => { + const provider = fakeProvider(); - const inputBox = filterBookmarks(provider as any) as unknown as InputBox; - inputBox.triggerChangeValue('auth'); - inputBox.triggerAccept(); + const inputBox = filterBookmarks(provider as any) as unknown as InputBox; + inputBox.triggerChangeValue('auth'); + inputBox.triggerAccept(); - expect(inputBox.visible).toBe(false); - expect(inputBox.disposed).toBe(true); - expect(provider.setSearchFilter).toHaveBeenCalledWith('auth'); - expect(provider.setSearchFilter).not.toHaveBeenCalledWith(null); - }); + expect(inputBox.visible).toBe(false); + expect(inputBox.disposed).toBe(true); + expect(provider.setSearchFilter).toHaveBeenCalledWith('auth'); + expect(provider.setSearchFilter).not.toHaveBeenCalledWith(null); + }); - it('clears the filter when hidden without accepting', () => { - const provider = fakeProvider(); + it('clears the filter when hidden without accepting', () => { + const provider = fakeProvider(); - const inputBox = filterBookmarks(provider as any) as unknown as InputBox; - inputBox.triggerChangeValue('auth'); - inputBox.hide(); + const inputBox = filterBookmarks(provider as any) as unknown as InputBox; + inputBox.triggerChangeValue('auth'); + inputBox.hide(); - expect(inputBox.disposed).toBe(true); - expect(provider.setSearchFilter).toHaveBeenCalledWith(null); - }); + expect(inputBox.disposed).toBe(true); + expect(provider.setSearchFilter).toHaveBeenCalledWith(null); + }); }); describe('deleteFolder', () => { - it('deletes an empty folder without confirming', async () => { - const store = newStore(); - const folder = store.createFolder('Backend'); + it('deletes an empty folder without confirming', async () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + + await deleteFolder(store, { folder, bookmarks: [], childFolders: [] } as any); + + expect(store.getAllFolders()).toHaveLength(0); + expect(window.showWarningMessage).not.toHaveBeenCalled(); + }); + + it('deletes a non-empty folder only after confirmation', async () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + window.showWarningMessage.mockResolvedValue('Delete'); + + await deleteFolder(store, { folder, bookmarks: [makeBookmark()], childFolders: [] } as any); + + expect(store.getAllFolders()).toHaveLength(0); + }); + + it('keeps a non-empty folder when the confirmation is declined', async () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + window.showWarningMessage.mockResolvedValue(undefined); + + await deleteFolder(store, { folder, bookmarks: [makeBookmark()], childFolders: [] } as any); + + expect(store.getAllFolders()).toHaveLength(1); + }); + + it('confirms and mentions subfolders when the folder has children but no bookmarks', async () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + const child = store.createFolder('Auth Service', folder.id); + window.showWarningMessage.mockResolvedValue('Delete'); + + await deleteFolder(store, { folder, bookmarks: [], childFolders: [child] } as any); + + expect(window.showWarningMessage.mock.calls[0][0]).toMatch( + /1 subfolder\(s\) will move to the root/, + ); + expect(store.getAllFolders().find((f) => f.id === folder.id)).toBeUndefined(); + }); + + it('mentions both bookmarks and subfolders when a folder has both', async () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + const child = store.createFolder('Auth Service', folder.id); + window.showWarningMessage.mockResolvedValue('Delete'); + + await deleteFolder(store, { + folder, + bookmarks: [makeBookmark()], + childFolders: [child], + } as any); + + expect(window.showWarningMessage.mock.calls[0][0]).toMatch( + /bookmark\(s\).*and.*subfolder\(s\)/, + ); + }); +}); - await deleteFolder(store, { folder, bookmarks: [] } as any); +describe('pickFolder', () => { + it('returns null for the root/no-folder pick', async () => { + const store = newStore(); + window.showQuickPick.mockResolvedValue('$(circle-slash) No Folder (root)'); - expect(store.getAllFolders()).toHaveLength(0); - expect(window.showWarningMessage).not.toHaveBeenCalled(); - }); + await expect(pickFolder(store, 'placeholder')).resolves.toBeNull(); + }); - it('deletes a non-empty folder only after confirmation', async () => { - const store = newStore(); - const folder = store.createFolder('Backend'); - window.showWarningMessage.mockResolvedValue('Delete'); + it('returns undefined when the quick pick is cancelled', async () => { + const store = newStore(); + window.showQuickPick.mockResolvedValue(undefined); - await deleteFolder(store, { folder, bookmarks: [makeBookmark()] } as any); + await expect(pickFolder(store, 'placeholder')).resolves.toBeUndefined(); + }); - expect(store.getAllFolders()).toHaveLength(0); - }); + it('returns the matching folder id for an existing folder', async () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + window.showQuickPick.mockResolvedValue('Backend'); - it('keeps a non-empty folder when the confirmation is declined', async () => { - const store = newStore(); - const folder = store.createFolder('Backend'); - window.showWarningMessage.mockResolvedValue(undefined); + await expect(pickFolder(store, 'placeholder')).resolves.toBe(folder.id); + }); - await deleteFolder(store, { folder, bookmarks: [makeBookmark()] } as any); + it('creates a new folder and returns its id', async () => { + const store = newStore(); + window.showQuickPick.mockResolvedValue('$(new-folder) New Folder...'); + window.showInputBox.mockResolvedValue('Frontend'); - expect(store.getAllFolders()).toHaveLength(1); - }); + const id = await pickFolder(store, 'placeholder'); + + expect(store.getAllFolders().find((f) => f.id === id)?.name).toBe('Frontend'); + + const validateInput = window.showInputBox.mock.calls[0][0].validateInput; + expect(validateInput(' ')).toMatch(/cannot be empty/); + expect(validateInput('Frontend')).toBeUndefined(); + }); + + it('returns undefined when creating a new folder is cancelled', async () => { + const store = newStore(); + window.showQuickPick.mockResolvedValue('$(new-folder) New Folder...'); + window.showInputBox.mockResolvedValue(undefined); + + await expect(pickFolder(store, 'placeholder')).resolves.toBeUndefined(); + }); }); -describe('pickFolder', () => { - it('returns null for the root/no-folder pick', async () => { - const store = newStore(); - window.showQuickPick.mockResolvedValue('$(circle-slash) No Folder (root)'); +describe('pickParentFolder', () => { + it('returns null for the root/no-parent pick', async () => { + const store = newStore(); + window.showQuickPick.mockResolvedValue('$(circle-slash) No Parent (root)'); + + await expect(pickParentFolder(store, 'placeholder')).resolves.toBeNull(); + }); + + it('returns undefined when the quick pick is cancelled', async () => { + const store = newStore(); + window.showQuickPick.mockResolvedValue(undefined); + + await expect(pickParentFolder(store, 'placeholder')).resolves.toBeUndefined(); + }); + + it('returns the matching folder id, using breadcrumb labels', async () => { + const store = newStore(); + const parent = store.createFolder('Backend'); + const child = store.createFolder('Auth Service', parent.id); + window.showQuickPick.mockResolvedValue('Backend › Auth Service'); + + await expect(pickParentFolder(store, 'placeholder')).resolves.toBe(child.id); + }); - await expect(pickFolder(store, 'placeholder')).resolves.toBeNull(); - }); + it('excludes the given folder and its descendants', async () => { + const store = newStore(); + const a = store.createFolder('A'); + store.createFolder('B', a.id); - it('returns undefined when the quick pick is cancelled', async () => { - const store = newStore(); - window.showQuickPick.mockResolvedValue(undefined); + await pickParentFolder(store, 'placeholder', a.id); - await expect(pickFolder(store, 'placeholder')).resolves.toBeUndefined(); - }); + const items = window.showQuickPick.mock.calls[0][0]; + expect(items).toEqual(['$(circle-slash) No Parent (root)']); + }); +}); + +describe('createSubfolder', () => { + it('creates a folder nested under the given item', async () => { + const store = newStore(); + const parent = store.createFolder('Backend'); + window.showInputBox.mockResolvedValue(' Auth Service '); + + await createSubfolder(store, { folder: parent } as any); + + const child = store.getAllFolders().find((f) => f.parentId === parent.id); + expect(child?.name).toBe('Auth Service'); + + const validateInput = window.showInputBox.mock.calls[0][0].validateInput; + expect(validateInput(' ')).toMatch(/cannot be empty/); + expect(validateInput('Auth Service')).toBeUndefined(); + }); - it('returns the matching folder id for an existing folder', async () => { - const store = newStore(); - const folder = store.createFolder('Backend'); - window.showQuickPick.mockResolvedValue('Backend'); + it('does nothing when the input is cancelled', async () => { + const store = newStore(); + const parent = store.createFolder('Backend'); + window.showInputBox.mockResolvedValue(undefined); - await expect(pickFolder(store, 'placeholder')).resolves.toBe(folder.id); - }); + await createSubfolder(store, { folder: parent } as any); + + expect(store.getAllFolders().filter((f) => f.parentId === parent.id)).toHaveLength(0); + }); + + it('refuses with a warning when nesting would exceed the max depth', async () => { + const store = newStore(); + const l1 = store.createFolder('L1'); + const l2 = store.createFolder('L2', l1.id); + const l3 = store.createFolder('L3', l2.id); + + await createSubfolder(store, { folder: l3 } as any); + + expect(window.showWarningMessage).toHaveBeenCalledOnce(); + expect(window.showInputBox).not.toHaveBeenCalled(); + }); +}); - it('creates a new folder and returns its id', async () => { - const store = newStore(); - window.showQuickPick.mockResolvedValue('$(new-folder) New Folder...'); - window.showInputBox.mockResolvedValue('Frontend'); +describe('moveFolderToParent', () => { + it('reparents the folder to the picked parent', async () => { + const store = newStore(); + const a = store.createFolder('A'); + const b = store.createFolder('B'); + window.showQuickPick.mockResolvedValue('A'); - const id = await pickFolder(store, 'placeholder'); + await moveFolderToParent(store, { folder: b } as any); - expect(store.getAllFolders().find(f => f.id === id)?.name).toBe('Frontend'); + expect(store.getAllFolders().find((f) => f.id === b.id)?.parentId).toBe(a.id); + }); - const validateInput = window.showInputBox.mock.calls[0][0].validateInput; - expect(validateInput(' ')).toMatch(/cannot be empty/); - expect(validateInput('Frontend')).toBeUndefined(); - }); + it('does nothing when the picker is cancelled', async () => { + const store = newStore(); + const a = store.createFolder('A'); + window.showQuickPick.mockResolvedValue(undefined); - it('returns undefined when creating a new folder is cancelled', async () => { - const store = newStore(); - window.showQuickPick.mockResolvedValue('$(new-folder) New Folder...'); - window.showInputBox.mockResolvedValue(undefined); + await moveFolderToParent(store, { folder: a } as any); - await expect(pickFolder(store, 'placeholder')).resolves.toBeUndefined(); - }); + expect(store.getAllFolders().find((f) => f.id === a.id)?.parentId).toBeNull(); + }); }); describe('moveToFolder', () => { - it('moves the bookmark to the picked folder', async () => { - const store = newStore(); - store.addBookmark(makeBookmark({ id: 'a' })); - const folder = store.createFolder('Backend'); - window.showQuickPick.mockResolvedValue('Backend'); + it('moves the bookmark to the picked folder', async () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a' })); + const folder = store.createFolder('Backend'); + window.showQuickPick.mockResolvedValue('Backend'); - await moveToFolder(store, { bookmark: makeBookmark({ id: 'a' }) } as any); + await moveToFolder(store, { bookmark: makeBookmark({ id: 'a' }) } as any); - expect(store.getAllBookmarks()[0].folderId).toBe(folder.id); - }); + expect(store.getAllBookmarks()[0].folderId).toBe(folder.id); + }); - it('does nothing when the picker is cancelled', async () => { - const store = newStore(); - store.addBookmark(makeBookmark({ id: 'a', folderId: 'existing' })); - window.showQuickPick.mockResolvedValue(undefined); + it('does nothing when the picker is cancelled', async () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a', folderId: 'existing' })); + window.showQuickPick.mockResolvedValue(undefined); - await moveToFolder(store, { bookmark: makeBookmark({ id: 'a' }) } as any); + await moveToFolder(store, { bookmark: makeBookmark({ id: 'a' }) } as any); - expect(store.getAllBookmarks()[0].folderId).toBe('existing'); - }); + expect(store.getAllBookmarks()[0].folderId).toBe('existing'); + }); }); describe('addBookmarksToFolder', () => { - it('adds every target uri into the picked folder', async () => { - const store = newStore(); - workspace.getWorkspaceFolder.mockReturnValue(undefined); - const folder = store.createFolder('Backend'); - window.showQuickPick.mockResolvedValue('Backend'); + it('adds every target uri into the picked folder', async () => { + const store = newStore(); + workspace.getWorkspaceFolder.mockReturnValue(undefined); + const folder = store.createFolder('Backend'); + window.showQuickPick.mockResolvedValue('Backend'); - await addBookmarksToFolder(store, undefined, [Uri.file('/repo/a.ts') as any, Uri.file('/repo/b.ts') as any]); + await addBookmarksToFolder(store, undefined, [ + Uri.file('/repo/a.ts') as any, + Uri.file('/repo/b.ts') as any, + ]); - const bookmarks = store.getAllBookmarks(); - expect(bookmarks).toHaveLength(2); - expect(bookmarks.every(b => b.folderId === folder.id)).toBe(true); - }); + const bookmarks = store.getAllBookmarks(); + expect(bookmarks).toHaveLength(2); + expect(bookmarks.every((b) => b.folderId === folder.id)).toBe(true); + }); - it('does nothing when there are no targets', async () => { - const store = newStore(); + it('does nothing when there are no targets', async () => { + const store = newStore(); - await addBookmarksToFolder(store, undefined, undefined); + await addBookmarksToFolder(store, undefined, undefined); - expect(window.showQuickPick).not.toHaveBeenCalled(); - }); + expect(window.showQuickPick).not.toHaveBeenCalled(); + }); - it('does nothing when the picker is cancelled', async () => { - const store = newStore(); - workspace.getWorkspaceFolder.mockReturnValue(undefined); - window.showQuickPick.mockResolvedValue(undefined); + it('does nothing when the picker is cancelled', async () => { + const store = newStore(); + workspace.getWorkspaceFolder.mockReturnValue(undefined); + window.showQuickPick.mockResolvedValue(undefined); - await addBookmarksToFolder(store, Uri.file('/repo/a.ts') as any, undefined); + await addBookmarksToFolder(store, Uri.file('/repo/a.ts') as any, undefined); - expect(store.getAllBookmarks()).toHaveLength(0); - }); + expect(store.getAllBookmarks()).toHaveLength(0); + }); }); diff --git a/test/dragAndDrop.test.ts b/test/dragAndDrop.test.ts index 0914d5a..378076b 100644 --- a/test/dragAndDrop.test.ts +++ b/test/dragAndDrop.test.ts @@ -1,293 +1,378 @@ import { describe, it, expect } from 'vitest'; import { - BOOKMARK_DND_MIME_TYPE, - BookmarkStore, - BookmarksTreeProvider, - BookmarkTreeItem, - FolderGroupItem, - computeReorderedIds, - dropBookmark, - dropFolder, - sortByOrder, - type Bookmark + BOOKMARK_DND_MIME_TYPE, + BookmarkStore, + BookmarksTreeProvider, + BookmarkTreeItem, + FolderGroupItem, + computeReorderedIds, + dropBookmark, + dropFolder, + sortByOrder, + type Bookmark, } from '../extension'; import { createFakeContext } from './fakeContext'; -import { DataTransfer, DataTransferItem } from './vscode-mock'; +import { DataTransfer, DataTransferItem, window } from './vscode-mock'; function makeBookmark(overrides: Partial = {}): Bookmark { - const id = overrides.id ?? 'id-1'; - return { - id, - uri: `file:///repo/src/${id}.ts`, - label: `${id}.ts`, - relativePath: `src/${id}.ts`, - workspaceFolderName: 'repo', - folderId: null, - createdAt: 0, - ...overrides - }; + const id = overrides.id ?? 'id-1'; + return { + id, + uri: `file:///repo/src/${id}.ts`, + label: `${id}.ts`, + relativePath: `src/${id}.ts`, + workspaceFolderName: 'repo', + folderId: null, + createdAt: 0, + ...overrides, + }; } function newStore() { - return new BookmarkStore(createFakeContext() as any); + return new BookmarkStore(createFakeContext() as any); } function newProvider() { - const context = createFakeContext(); - const store = new BookmarkStore(context as any); - const provider = new BookmarksTreeProvider(store, context as any); - return { store, provider }; + const context = createFakeContext(); + const store = new BookmarkStore(context as any); + const provider = new BookmarksTreeProvider(store, context as any); + return { store, provider }; } describe('sortByOrder', () => { - it('sorts by order ascending when every item has one', () => { - const items = [{ id: 'b', order: 1 }, { id: 'a', order: 0 }]; - expect(sortByOrder(items, () => 0).map(i => i.id)).toEqual(['a', 'b']); - }); - - it('puts ordered items before unordered ones', () => { - const items = [{ id: 'unordered' }, { id: 'ordered', order: 0 }]; - expect(sortByOrder(items, () => 0).map(i => i.id)).toEqual(['ordered', 'unordered']); - }); - - it('puts ordered items before unordered ones regardless of comparison direction', () => { - const items = [{ id: 'ordered', order: 0 }, { id: 'unordered' }]; - expect(sortByOrder(items, () => 0).map(i => i.id)).toEqual(['ordered', 'unordered']); - }); - - it('falls back to the comparator when neither item has an order', () => { - const items: Array<{ id: string; createdAt: number; order?: number }> = [ - { id: 'a', createdAt: 1 }, - { id: 'b', createdAt: 2 } - ]; - expect(sortByOrder(items, (a, b) => b.createdAt - a.createdAt).map(i => i.id)).toEqual(['b', 'a']); - }); + it('sorts by order ascending when every item has one', () => { + const items = [ + { id: 'b', order: 1 }, + { id: 'a', order: 0 }, + ]; + expect(sortByOrder(items, () => 0).map((i) => i.id)).toEqual(['a', 'b']); + }); + + it('puts ordered items before unordered ones', () => { + const items = [{ id: 'unordered' }, { id: 'ordered', order: 0 }]; + expect(sortByOrder(items, () => 0).map((i) => i.id)).toEqual(['ordered', 'unordered']); + }); + + it('puts ordered items before unordered ones regardless of comparison direction', () => { + const items = [{ id: 'ordered', order: 0 }, { id: 'unordered' }]; + expect(sortByOrder(items, () => 0).map((i) => i.id)).toEqual(['ordered', 'unordered']); + }); + + it('falls back to the comparator when neither item has an order', () => { + const items: Array<{ id: string; createdAt: number; order?: number }> = [ + { id: 'a', createdAt: 1 }, + { id: 'b', createdAt: 2 }, + ]; + expect(sortByOrder(items, (a, b) => b.createdAt - a.createdAt).map((i) => i.id)).toEqual([ + 'b', + 'a', + ]); + }); }); describe('computeReorderedIds', () => { - it('appends the dragged id at the end when beforeId is null', () => { - const items = [{ id: 'a', order: 0 }, { id: 'b', order: 1 }]; - expect(computeReorderedIds(items, 'a', null, () => 0)).toEqual(['b', 'a']); - }); - - it('inserts the dragged id before beforeId', () => { - const items = [{ id: 'a', order: 0 }, { id: 'b', order: 1 }, { id: 'c', order: 2 }]; - expect(computeReorderedIds(items, 'c', 'a', () => 0)).toEqual(['c', 'a', 'b']); - }); + it('appends the dragged id at the end when beforeId is null', () => { + const items = [ + { id: 'a', order: 0 }, + { id: 'b', order: 1 }, + ]; + expect(computeReorderedIds(items, 'a', null, () => 0)).toEqual(['b', 'a']); + }); + + it('inserts the dragged id before beforeId', () => { + const items = [ + { id: 'a', order: 0 }, + { id: 'b', order: 1 }, + { id: 'c', order: 2 }, + ]; + expect(computeReorderedIds(items, 'c', 'a', () => 0)).toEqual(['c', 'a', 'b']); + }); }); describe('dropFolder', () => { - it('reorders a folder to just before the target folder', () => { - const store = newStore(); - const a = store.createFolder('A'); - const b = store.createFolder('B'); - const c = store.createFolder('C'); + it('nests the dragged folder as the last child of the target folder', () => { + const store = newStore(); + const a = store.createFolder('A'); + const b = store.createFolder('B'); - dropFolder(store, c.id, new FolderGroupItem(a, [])); + dropFolder(store, b.id, new FolderGroupItem(a, [])); - const byId = new Map(store.getAllFolders().map(f => [f.id, f])); - expect(byId.get(c.id)?.order).toBe(0); - expect(byId.get(a.id)?.order).toBe(1); - expect(byId.get(b.id)?.order).toBe(2); - }); + const byId = new Map(store.getAllFolders().map((f) => [f.id, f])); + expect(byId.get(b.id)?.parentId).toBe(a.id); + expect(byId.get(b.id)?.order).toBe(0); + }); - it('moves a folder to the end when dropped on empty space', () => { - const store = newStore(); - const a = store.createFolder('A'); - const b = store.createFolder('B'); + it("reorders within the target folder's existing children when nesting", () => { + const store = newStore(); + const parent = store.createFolder('Parent'); + const existingChild = store.createFolder('Existing', parent.id); + const root = store.createFolder('Root'); - dropFolder(store, a.id, undefined); + dropFolder(store, root.id, new FolderGroupItem(parent, [])); - const byId = new Map(store.getAllFolders().map(f => [f.id, f])); - expect(byId.get(b.id)?.order).toBe(0); - expect(byId.get(a.id)?.order).toBe(1); - }); + const byId = new Map(store.getAllFolders().map((f) => [f.id, f])); + expect(byId.get(existingChild.id)?.order).toBe(0); + expect(byId.get(root.id)?.order).toBe(1); + expect(byId.get(root.id)?.parentId).toBe(parent.id); + }); - it('does nothing when dropped onto itself', () => { - const store = newStore(); - const a = store.createFolder('A'); + it('refuses to nest a folder onto itself', () => { + const store = newStore(); + const a = store.createFolder('A'); - dropFolder(store, a.id, new FolderGroupItem(a, [])); + dropFolder(store, a.id, new FolderGroupItem(a, [])); - expect(store.getAllFolders()[0].order).toBeUndefined(); - }); + expect(store.getAllFolders()[0].order).toBeUndefined(); + }); - it('ignores a drop onto a bookmark item', () => { - const store = newStore(); - const a = store.createFolder('A'); - store.addBookmark(makeBookmark({ id: 'bm' })); + it('refuses to nest a folder onto its own descendant', () => { + const store = newStore(); + const parent = store.createFolder('Parent'); + const child = store.createFolder('Child', parent.id); - dropFolder(store, a.id, new BookmarkTreeItem(makeBookmark({ id: 'bm' }), [])); + dropFolder(store, parent.id, new FolderGroupItem(child, [])); - expect(store.getAllFolders()[0].order).toBeUndefined(); - }); -}); - -describe('dropBookmark', () => { - it('moves a bookmark into the target folder and appends it', () => { - const store = newStore(); - const folder = store.createFolder('Backend'); - store.addBookmark(makeBookmark({ id: 'existing', folderId: folder.id })); - store.addBookmark(makeBookmark({ id: 'dragged', folderId: null })); - - dropBookmark(store, 'dragged', new FolderGroupItem(folder, []), 'tree'); - - const byId = new Map(store.getAllBookmarks().map(b => [b.id, b])); - expect(byId.get('dragged')?.folderId).toBe(folder.id); - expect(byId.get('existing')?.order).toBe(0); - expect(byId.get('dragged')?.order).toBe(1); - }); - - it('reorders within the same folder scope in tree view without changing folderId', () => { - const store = newStore(); - const folder = store.createFolder('Backend'); - store.addBookmark(makeBookmark({ id: 'a', folderId: folder.id })); - store.addBookmark(makeBookmark({ id: 'b', folderId: folder.id })); - - dropBookmark(store, 'b', new BookmarkTreeItem(makeBookmark({ id: 'a', folderId: folder.id }), []), 'tree'); - - const byId = new Map(store.getAllBookmarks().map(bm => [bm.id, bm])); - expect(byId.get('b')?.folderId).toBe(folder.id); - expect(byId.get('b')?.order).toBe(0); - expect(byId.get('a')?.order).toBe(1); - }); - - it('moves folders when dropped onto a bookmark in a different folder in tree view', () => { - const store = newStore(); - const source = store.createFolder('Source'); - const dest = store.createFolder('Dest'); - store.addBookmark(makeBookmark({ id: 'dragged', folderId: source.id })); - store.addBookmark(makeBookmark({ id: 'target', folderId: dest.id })); - - dropBookmark(store, 'dragged', new BookmarkTreeItem(makeBookmark({ id: 'target', folderId: dest.id }), []), 'tree'); - - const byId = new Map(store.getAllBookmarks().map(bm => [bm.id, bm])); - expect(byId.get('dragged')?.folderId).toBe(dest.id); - expect(byId.get('dragged')?.order).toBe(0); - expect(byId.get('target')?.order).toBe(1); - }); - - it('reorders in place in list view without reassigning folderId', () => { - const store = newStore(); - const folder = store.createFolder('Backend'); - store.addBookmark(makeBookmark({ id: 'grouped', folderId: folder.id })); - store.addBookmark(makeBookmark({ id: 'root', folderId: null })); - - dropBookmark(store, 'root', new BookmarkTreeItem(makeBookmark({ id: 'grouped', folderId: folder.id }), []), 'list'); - - const byId = new Map(store.getAllBookmarks().map(bm => [bm.id, bm])); - expect(byId.get('root')?.folderId).toBeNull(); - expect(byId.get('root')?.order).toBe(0); - expect(byId.get('grouped')?.order).toBe(1); - }); - - it('moves a bookmark to the root and appends it when dropped on empty space in tree view', () => { - const store = newStore(); - const folder = store.createFolder('Backend'); - store.addBookmark(makeBookmark({ id: 'root-existing', folderId: null })); - store.addBookmark(makeBookmark({ id: 'dragged', folderId: folder.id })); - - dropBookmark(store, 'dragged', undefined, 'tree'); - - const byId = new Map(store.getAllBookmarks().map(bm => [bm.id, bm])); - expect(byId.get('dragged')?.folderId).toBeNull(); - expect(byId.get('root-existing')?.order).toBe(0); - expect(byId.get('dragged')?.order).toBe(1); - }); - - it('appends to the end of the flat list when dropped on empty space in list view', () => { - const store = newStore(); - store.addBookmark(makeBookmark({ id: 'a' })); - store.addBookmark(makeBookmark({ id: 'dragged' })); - - dropBookmark(store, 'dragged', undefined, 'list'); - - const byId = new Map(store.getAllBookmarks().map(bm => [bm.id, bm])); - expect(byId.get('a')?.order).toBe(0); - expect(byId.get('dragged')?.order).toBe(1); - }); - - it('does nothing when the dragged bookmark no longer exists', () => { - const store = newStore(); - store.addBookmark(makeBookmark({ id: 'a' })); - - dropBookmark(store, 'missing', undefined, 'tree'); - - expect(store.getAllBookmarks()[0].order).toBeUndefined(); - }); - - it('does nothing when dropped onto itself', () => { - const store = newStore(); - store.addBookmark(makeBookmark({ id: 'a' })); - - dropBookmark(store, 'a', new BookmarkTreeItem(makeBookmark({ id: 'a' }), []), 'tree'); - - expect(store.getAllBookmarks()[0].order).toBeUndefined(); - }); -}); - -describe('BookmarksTreeProvider drag and drop', () => { - it('exposes the same mime type for drag and drop', () => { - const { provider } = newProvider(); - expect(provider.dragMimeTypes).toEqual([BOOKMARK_DND_MIME_TYPE]); - expect(provider.dropMimeTypes).toEqual([BOOKMARK_DND_MIME_TYPE]); - }); + const byId = new Map(store.getAllFolders().map((f) => [f.id, f])); + expect(byId.get(parent.id)?.parentId).toBeNull(); + expect(byId.get(parent.id)?.order).toBeUndefined(); + }); - it('handleDrag stores the bookmark id under the mime type', async () => { - const { provider } = newProvider(); - const item = new BookmarkTreeItem(makeBookmark({ id: 'a' }), []); - const dataTransfer = new DataTransfer(); + it('refuses to nest when it would exceed the max folder depth, with a warning', () => { + const store = newStore(); + const level1 = store.createFolder('L1'); + const level2 = store.createFolder('L2', level1.id); + const level3 = store.createFolder('L3', level2.id); + const dragged = store.createFolder('Dragged'); - provider.handleDrag([item], dataTransfer as any); + dropFolder(store, dragged.id, new FolderGroupItem(level3, [])); - const stored = await dataTransfer.get(BOOKMARK_DND_MIME_TYPE)?.asString(); - expect(JSON.parse(stored ?? '{}')).toEqual({ kind: 'bookmark', id: 'a' }); - }); + expect(store.getAllFolders().find((f) => f.id === dragged.id)?.parentId).toBeNull(); + expect(window.showWarningMessage).toHaveBeenCalledOnce(); + }); - it('handleDrag does nothing when the source list is empty', () => { - const { provider } = newProvider(); - const dataTransfer = new DataTransfer(); + it('moves a folder to the root and appends it when dropped on empty space', () => { + const store = newStore(); + const parent = store.createFolder('Parent'); + const nested = store.createFolder('Nested', parent.id); - provider.handleDrag([], dataTransfer as any); + dropFolder(store, nested.id, undefined); - expect(dataTransfer.get(BOOKMARK_DND_MIME_TYPE)).toBeUndefined(); - }); + const byId = new Map(store.getAllFolders().map((f) => [f.id, f])); + expect(byId.get(nested.id)?.parentId).toBeNull(); + expect(byId.get(parent.id)?.order).toBe(0); + expect(byId.get(nested.id)?.order).toBe(1); + }); - it('handleDrop reorders folders end-to-end via the data transfer payload', async () => { - const { store, provider } = newProvider(); - const a = store.createFolder('A'); - const b = store.createFolder('B'); - const dataTransfer = new DataTransfer(); - dataTransfer.set(BOOKMARK_DND_MIME_TYPE, new DataTransferItem(JSON.stringify({ kind: 'folder', id: b.id }))); + it('does nothing when the dragged folder no longer exists', () => { + const store = newStore(); + const a = store.createFolder('A'); - await provider.handleDrop(new FolderGroupItem(a, []), dataTransfer as any); + dropFolder(store, 'missing', undefined); - const byId = new Map(store.getAllFolders().map(f => [f.id, f])); - expect(byId.get(b.id)?.order).toBe(0); - expect(byId.get(a.id)?.order).toBe(1); - }); + expect(store.getAllFolders()[0].order).toBeUndefined(); + void a; + }); - it('handleDrop reorders bookmarks end-to-end via the data transfer payload', async () => { - const { store, provider } = newProvider(); - store.addBookmark(makeBookmark({ id: 'a' })); - store.addBookmark(makeBookmark({ id: 'b' })); - const dataTransfer = new DataTransfer(); - dataTransfer.set(BOOKMARK_DND_MIME_TYPE, new DataTransferItem(JSON.stringify({ kind: 'bookmark', id: 'b' }))); + it('ignores a drop onto a bookmark item', () => { + const store = newStore(); + const a = store.createFolder('A'); + store.addBookmark(makeBookmark({ id: 'bm' })); - await provider.handleDrop(new BookmarkTreeItem(makeBookmark({ id: 'a' }), []), dataTransfer as any); + dropFolder(store, a.id, new BookmarkTreeItem(makeBookmark({ id: 'bm' }), [])); - const byId = new Map(store.getAllBookmarks().map(bm => [bm.id, bm])); - expect(byId.get('b')?.order).toBe(0); - expect(byId.get('a')?.order).toBe(1); - }); - - it('handleDrop does nothing when the data transfer has no matching payload', async () => { - const { store, provider } = newProvider(); - store.addBookmark(makeBookmark({ id: 'a' })); - const dataTransfer = new DataTransfer(); + expect(store.getAllFolders()[0].order).toBeUndefined(); + }); +}); - await provider.handleDrop(undefined, dataTransfer as any); +describe('dropBookmark', () => { + it('moves a bookmark into the target folder and appends it', () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + store.addBookmark(makeBookmark({ id: 'existing', folderId: folder.id })); + store.addBookmark(makeBookmark({ id: 'dragged', folderId: null })); + + dropBookmark(store, 'dragged', new FolderGroupItem(folder, []), 'tree'); + + const byId = new Map(store.getAllBookmarks().map((b) => [b.id, b])); + expect(byId.get('dragged')?.folderId).toBe(folder.id); + expect(byId.get('existing')?.order).toBe(0); + expect(byId.get('dragged')?.order).toBe(1); + }); + + it('reorders within the same folder scope in tree view without changing folderId', () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + store.addBookmark(makeBookmark({ id: 'a', folderId: folder.id })); + store.addBookmark(makeBookmark({ id: 'b', folderId: folder.id })); + + dropBookmark( + store, + 'b', + new BookmarkTreeItem(makeBookmark({ id: 'a', folderId: folder.id }), []), + 'tree', + ); + + const byId = new Map(store.getAllBookmarks().map((bm) => [bm.id, bm])); + expect(byId.get('b')?.folderId).toBe(folder.id); + expect(byId.get('b')?.order).toBe(0); + expect(byId.get('a')?.order).toBe(1); + }); + + it('moves folders when dropped onto a bookmark in a different folder in tree view', () => { + const store = newStore(); + const source = store.createFolder('Source'); + const dest = store.createFolder('Dest'); + store.addBookmark(makeBookmark({ id: 'dragged', folderId: source.id })); + store.addBookmark(makeBookmark({ id: 'target', folderId: dest.id })); + + dropBookmark( + store, + 'dragged', + new BookmarkTreeItem(makeBookmark({ id: 'target', folderId: dest.id }), []), + 'tree', + ); + + const byId = new Map(store.getAllBookmarks().map((bm) => [bm.id, bm])); + expect(byId.get('dragged')?.folderId).toBe(dest.id); + expect(byId.get('dragged')?.order).toBe(0); + expect(byId.get('target')?.order).toBe(1); + }); + + it('reorders in place in list view without reassigning folderId', () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + store.addBookmark(makeBookmark({ id: 'grouped', folderId: folder.id })); + store.addBookmark(makeBookmark({ id: 'root', folderId: null })); + + dropBookmark( + store, + 'root', + new BookmarkTreeItem(makeBookmark({ id: 'grouped', folderId: folder.id }), []), + 'list', + ); + + const byId = new Map(store.getAllBookmarks().map((bm) => [bm.id, bm])); + expect(byId.get('root')?.folderId).toBeNull(); + expect(byId.get('root')?.order).toBe(0); + expect(byId.get('grouped')?.order).toBe(1); + }); + + it('moves a bookmark to the root and appends it when dropped on empty space in tree view', () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + store.addBookmark(makeBookmark({ id: 'root-existing', folderId: null })); + store.addBookmark(makeBookmark({ id: 'dragged', folderId: folder.id })); + + dropBookmark(store, 'dragged', undefined, 'tree'); + + const byId = new Map(store.getAllBookmarks().map((bm) => [bm.id, bm])); + expect(byId.get('dragged')?.folderId).toBeNull(); + expect(byId.get('root-existing')?.order).toBe(0); + expect(byId.get('dragged')?.order).toBe(1); + }); + + it('appends to the end of the flat list when dropped on empty space in list view', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a' })); + store.addBookmark(makeBookmark({ id: 'dragged' })); + + dropBookmark(store, 'dragged', undefined, 'list'); + + const byId = new Map(store.getAllBookmarks().map((bm) => [bm.id, bm])); + expect(byId.get('a')?.order).toBe(0); + expect(byId.get('dragged')?.order).toBe(1); + }); + + it('does nothing when the dragged bookmark no longer exists', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a' })); + + dropBookmark(store, 'missing', undefined, 'tree'); + + expect(store.getAllBookmarks()[0].order).toBeUndefined(); + }); + + it('does nothing when dropped onto itself', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a' })); + + dropBookmark(store, 'a', new BookmarkTreeItem(makeBookmark({ id: 'a' }), []), 'tree'); + + expect(store.getAllBookmarks()[0].order).toBeUndefined(); + }); +}); - expect(store.getAllBookmarks()[0].order).toBeUndefined(); - }); +describe('BookmarksTreeProvider drag and drop', () => { + it('exposes the same mime type for drag and drop', () => { + const { provider } = newProvider(); + expect(provider.dragMimeTypes).toEqual([BOOKMARK_DND_MIME_TYPE]); + expect(provider.dropMimeTypes).toEqual([BOOKMARK_DND_MIME_TYPE]); + }); + + it('handleDrag stores the bookmark id under the mime type', async () => { + const { provider } = newProvider(); + const item = new BookmarkTreeItem(makeBookmark({ id: 'a' }), []); + const dataTransfer = new DataTransfer(); + + provider.handleDrag([item], dataTransfer as any); + + const stored = await dataTransfer.get(BOOKMARK_DND_MIME_TYPE)?.asString(); + expect(JSON.parse(stored ?? '{}')).toEqual({ kind: 'bookmark', id: 'a' }); + }); + + it('handleDrag does nothing when the source list is empty', () => { + const { provider } = newProvider(); + const dataTransfer = new DataTransfer(); + + provider.handleDrag([], dataTransfer as any); + + expect(dataTransfer.get(BOOKMARK_DND_MIME_TYPE)).toBeUndefined(); + }); + + it('handleDrop nests a folder end-to-end via the data transfer payload', async () => { + const { store, provider } = newProvider(); + const a = store.createFolder('A'); + const b = store.createFolder('B'); + const dataTransfer = new DataTransfer(); + dataTransfer.set( + BOOKMARK_DND_MIME_TYPE, + new DataTransferItem(JSON.stringify({ kind: 'folder', id: b.id })), + ); + + await provider.handleDrop(new FolderGroupItem(a, []), dataTransfer as any); + + const byId = new Map(store.getAllFolders().map((f) => [f.id, f])); + expect(byId.get(b.id)?.parentId).toBe(a.id); + expect(byId.get(b.id)?.order).toBe(0); + }); + + it('handleDrop reorders bookmarks end-to-end via the data transfer payload', async () => { + const { store, provider } = newProvider(); + store.addBookmark(makeBookmark({ id: 'a' })); + store.addBookmark(makeBookmark({ id: 'b' })); + const dataTransfer = new DataTransfer(); + dataTransfer.set( + BOOKMARK_DND_MIME_TYPE, + new DataTransferItem(JSON.stringify({ kind: 'bookmark', id: 'b' })), + ); + + await provider.handleDrop( + new BookmarkTreeItem(makeBookmark({ id: 'a' }), []), + dataTransfer as any, + ); + + const byId = new Map(store.getAllBookmarks().map((bm) => [bm.id, bm])); + expect(byId.get('b')?.order).toBe(0); + expect(byId.get('a')?.order).toBe(1); + }); + + it('handleDrop does nothing when the data transfer has no matching payload', async () => { + const { store, provider } = newProvider(); + store.addBookmark(makeBookmark({ id: 'a' })); + const dataTransfer = new DataTransfer(); + + await provider.handleDrop(undefined, dataTransfer as any); + + expect(store.getAllBookmarks()[0].order).toBeUndefined(); + }); }); diff --git a/test/e2e/extension.e2e.test.ts b/test/e2e/extension.e2e.test.ts index 5d17dc5..3468bf5 100644 --- a/test/e2e/extension.e2e.test.ts +++ b/test/e2e/extension.e2e.test.ts @@ -8,158 +8,197 @@ import type { BookmarkTreeItem, ExtensionApi, FolderGroupItem } from '../../exte // module instance and `instanceof` across the two would silently always fail. Duck-type // tree nodes by shape instead of importing the classes as values. function isBookmarkNode(node: BookmarkTreeItem | FolderGroupItem): node is BookmarkTreeItem { - return 'bookmark' in node; + return 'bookmark' in node; } const EXTENSION_ID = 'Wikid82.workspace-file-bookmarks'; async function getApi(): Promise { - const extension = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(extension, `extension "${EXTENSION_ID}" is not installed in the test host`); - return extension.isActive ? extension.exports : await extension.activate(); + const extension = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(extension, `extension "${EXTENSION_ID}" is not installed in the test host`); + return extension.isActive ? extension.exports : await extension.activate(); } function fixtureUri(relativePath: string): vscode.Uri { - const [workspaceFolder] = vscode.workspace.workspaceFolders ?? []; - assert.ok(workspaceFolder, 'expected the e2e fixture workspace to be open'); - return vscode.Uri.file(path.join(workspaceFolder.uri.fsPath, relativePath)); + const [workspaceFolder] = vscode.workspace.workspaceFolders ?? []; + assert.ok(workspaceFolder, 'expected the e2e fixture workspace to be open'); + return vscode.Uri.file(path.join(workspaceFolder.uri.fsPath, relativePath)); } describe('Workspace File Bookmarks (e2e)', () => { - beforeEach(async () => { - const { store, provider } = await getApi(); - for (const bookmark of store.getAllBookmarks()) { - store.removeBookmark(bookmark.id); - } - for (const folder of store.getAllFolders()) { - store.deleteFolder(folder.id); - } - provider.setTagFilter(null); - provider.setSearchFilter(null); - }); - - it('activates and registers every contributed command', async () => { - await getApi(); - const commands = await vscode.commands.getCommands(true); - - for (const id of [ - 'workspace-file-bookmarks.addBookmark', - 'workspace-file-bookmarks.addBookmarkFromExplorer', - 'workspace-file-bookmarks.addBookmarkToFolder', - 'workspace-file-bookmarks.removeBookmark', - 'workspace-file-bookmarks.renameBookmark', - 'workspace-file-bookmarks.editTags', - 'workspace-file-bookmarks.filterByTag', - 'workspace-file-bookmarks.clearTagFilter', - 'workspace-file-bookmarks.filterBookmarks', - 'workspace-file-bookmarks.clearSearchFilter', - 'workspace-file-bookmarks.openBookmark', - 'workspace-file-bookmarks.createFolder', - 'workspace-file-bookmarks.renameFolder', - 'workspace-file-bookmarks.deleteFolder', - 'workspace-file-bookmarks.moveToFolder', - 'workspace-file-bookmarks.openAllInFolder', - 'workspace-file-bookmarks.setViewModeList', - 'workspace-file-bookmarks.setViewModeTree' - ]) { - assert.ok(commands.includes(id), `expected command "${id}" to be registered`); - } - }); - - it('bookmarks the active editor via the addBookmark command and lists it in the tree', async () => { - const { store, provider } = await getApi(); - const document = await vscode.workspace.openTextDocument(fixtureUri('src/sample-a.ts')); - await vscode.window.showTextDocument(document); - - await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); - - const bookmarks = store.getAllBookmarks(); - assert.equal(bookmarks.length, 1); - assert.equal(bookmarks[0].relativePath, 'src/sample-a.ts'); - - const children = provider.getChildren() as BookmarkTreeItem[]; - assert.equal(children.length, 1); - assert.equal(children[0].bookmark.relativePath, 'src/sample-a.ts'); - }); - - it('removes a bookmark via the removeBookmark command', async () => { - const { store, provider } = await getApi(); - const document = await vscode.workspace.openTextDocument(fixtureUri('src/sample-a.ts')); - await vscode.window.showTextDocument(document); - await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); - const [item] = provider.getChildren() as BookmarkTreeItem[]; - - await vscode.commands.executeCommand('workspace-file-bookmarks.removeBookmark', item); - - assert.deepEqual(store.getAllBookmarks(), []); - }); - - it('groups a bookmark under a folder created and assigned via commands', async () => { - const { store, provider } = await getApi(); - const folder = store.createFolder('Backend'); - const document = await vscode.workspace.openTextDocument(fixtureUri('src/sample-b.ts')); - await vscode.window.showTextDocument(document); - await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); - const [bookmarkItem] = (provider.getChildren() as (BookmarkTreeItem | FolderGroupItem)[]).filter(isBookmarkNode); - - store.moveBookmarkToFolder(bookmarkItem.bookmark.id, folder.id); - - const [folderNode] = provider.getChildren() as FolderGroupItem[]; - assert.equal(folderNode.folder.name, 'Backend'); - const folderChildren = provider.getChildren(folderNode) as BookmarkTreeItem[]; - assert.equal(folderChildren.length, 1); - assert.equal(folderChildren[0].bookmark.relativePath, 'src/sample-b.ts'); - }); - - it('shows tags in the tree description and narrows the tree via the active tag filter', async () => { - const { store, provider } = await getApi(); - const tagged = await vscode.workspace.openTextDocument(fixtureUri('src/sample-a.ts')); - await vscode.window.showTextDocument(tagged); - await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); - const untagged = await vscode.workspace.openTextDocument(fixtureUri('src/sample-b.ts')); - await vscode.window.showTextDocument(untagged); - await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); - const [taggedBookmark] = store.getAllBookmarks().filter(b => b.relativePath === 'src/sample-a.ts'); - store.setBookmarkTags(taggedBookmark.id, ['auth']); - - const taggedItem = (provider.getChildren() as BookmarkTreeItem[]).find(c => c.bookmark.id === taggedBookmark.id); - assert.ok(taggedItem?.description?.toString().includes('#auth')); - - provider.setTagFilter('auth'); - const filtered = provider.getChildren() as BookmarkTreeItem[]; - assert.equal(filtered.length, 1); - assert.equal(filtered[0].bookmark.id, taggedBookmark.id); - - provider.setTagFilter(null); - assert.equal((provider.getChildren() as BookmarkTreeItem[]).length, 2); - }); - - it('narrows the tree via the active search filter, matching label, path, or repo', async () => { - const { store, provider } = await getApi(); - const matching = await vscode.workspace.openTextDocument(fixtureUri('src/sample-a.ts')); - await vscode.window.showTextDocument(matching); - await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); - const other = await vscode.workspace.openTextDocument(fixtureUri('src/sample-b.ts')); - await vscode.window.showTextDocument(other); - await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); - const [matchingBookmark] = store.getAllBookmarks().filter(b => b.relativePath === 'src/sample-a.ts'); - - provider.setSearchFilter('sample-a'); - const filtered = provider.getChildren() as BookmarkTreeItem[]; - assert.equal(filtered.length, 1); - assert.equal(filtered[0].bookmark.id, matchingBookmark.id); - - provider.setSearchFilter(null); - assert.equal((provider.getChildren() as BookmarkTreeItem[]).length, 2); - }); - - it('opens a live filter input box via the filterBookmarks command', async () => { - const { provider } = await getApi(); - - const inputBox = await vscode.commands.executeCommand('workspace-file-bookmarks.filterBookmarks'); - - assert.ok(inputBox, 'expected the filterBookmarks command to return the created input box'); - inputBox.hide(); - provider.setSearchFilter(null); - }); + beforeEach(async () => { + const { store, provider } = await getApi(); + for (const bookmark of store.getAllBookmarks()) { + store.removeBookmark(bookmark.id); + } + for (const folder of store.getAllFolders()) { + store.deleteFolder(folder.id); + } + provider.setTagFilter(null); + provider.setSearchFilter(null); + }); + + it('activates and registers every contributed command', async () => { + await getApi(); + const commands = await vscode.commands.getCommands(true); + + for (const id of [ + 'workspace-file-bookmarks.addBookmark', + 'workspace-file-bookmarks.addBookmarkFromExplorer', + 'workspace-file-bookmarks.addBookmarkToFolder', + 'workspace-file-bookmarks.removeBookmark', + 'workspace-file-bookmarks.renameBookmark', + 'workspace-file-bookmarks.editTags', + 'workspace-file-bookmarks.filterByTag', + 'workspace-file-bookmarks.clearTagFilter', + 'workspace-file-bookmarks.filterBookmarks', + 'workspace-file-bookmarks.clearSearchFilter', + 'workspace-file-bookmarks.openBookmark', + 'workspace-file-bookmarks.createFolder', + 'workspace-file-bookmarks.createSubfolder', + 'workspace-file-bookmarks.moveFolderToParent', + 'workspace-file-bookmarks.renameFolder', + 'workspace-file-bookmarks.deleteFolder', + 'workspace-file-bookmarks.moveToFolder', + 'workspace-file-bookmarks.openAllInFolder', + 'workspace-file-bookmarks.setViewModeList', + 'workspace-file-bookmarks.setViewModeTree', + ]) { + assert.ok(commands.includes(id), `expected command "${id}" to be registered`); + } + }); + + it('bookmarks the active editor via the addBookmark command and lists it in the tree', async () => { + const { store, provider } = await getApi(); + const document = await vscode.workspace.openTextDocument(fixtureUri('src/sample-a.ts')); + await vscode.window.showTextDocument(document); + + await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); + + const bookmarks = store.getAllBookmarks(); + assert.equal(bookmarks.length, 1); + assert.equal(bookmarks[0].relativePath, 'src/sample-a.ts'); + + const children = provider.getChildren() as BookmarkTreeItem[]; + assert.equal(children.length, 1); + assert.equal(children[0].bookmark.relativePath, 'src/sample-a.ts'); + }); + + it('removes a bookmark via the removeBookmark command', async () => { + const { store, provider } = await getApi(); + const document = await vscode.workspace.openTextDocument(fixtureUri('src/sample-a.ts')); + await vscode.window.showTextDocument(document); + await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); + const [item] = provider.getChildren() as BookmarkTreeItem[]; + + await vscode.commands.executeCommand('workspace-file-bookmarks.removeBookmark', item); + + assert.deepEqual(store.getAllBookmarks(), []); + }); + + it('groups a bookmark under a folder created and assigned via commands', async () => { + const { store, provider } = await getApi(); + const folder = store.createFolder('Backend'); + const document = await vscode.workspace.openTextDocument(fixtureUri('src/sample-b.ts')); + await vscode.window.showTextDocument(document); + await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); + const [bookmarkItem] = ( + provider.getChildren() as (BookmarkTreeItem | FolderGroupItem)[] + ).filter(isBookmarkNode); + + store.moveBookmarkToFolder(bookmarkItem.bookmark.id, folder.id); + + const [folderNode] = provider.getChildren() as FolderGroupItem[]; + assert.equal(folderNode.folder.name, 'Backend'); + const folderChildren = provider.getChildren(folderNode) as BookmarkTreeItem[]; + assert.equal(folderChildren.length, 1); + assert.equal(folderChildren[0].bookmark.relativePath, 'src/sample-b.ts'); + }); + + it('shows tags in the tree description and narrows the tree via the active tag filter', async () => { + const { store, provider } = await getApi(); + const tagged = await vscode.workspace.openTextDocument(fixtureUri('src/sample-a.ts')); + await vscode.window.showTextDocument(tagged); + await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); + const untagged = await vscode.workspace.openTextDocument(fixtureUri('src/sample-b.ts')); + await vscode.window.showTextDocument(untagged); + await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); + const [taggedBookmark] = store + .getAllBookmarks() + .filter((b) => b.relativePath === 'src/sample-a.ts'); + store.setBookmarkTags(taggedBookmark.id, ['auth']); + + const taggedItem = (provider.getChildren() as BookmarkTreeItem[]).find( + (c) => c.bookmark.id === taggedBookmark.id, + ); + assert.ok(taggedItem?.description?.toString().includes('#auth')); + + provider.setTagFilter('auth'); + const filtered = provider.getChildren() as BookmarkTreeItem[]; + assert.equal(filtered.length, 1); + assert.equal(filtered[0].bookmark.id, taggedBookmark.id); + + provider.setTagFilter(null); + assert.equal((provider.getChildren() as BookmarkTreeItem[]).length, 2); + }); + + it('narrows the tree via the active search filter, matching label, path, or repo', async () => { + const { store, provider } = await getApi(); + const matching = await vscode.workspace.openTextDocument(fixtureUri('src/sample-a.ts')); + await vscode.window.showTextDocument(matching); + await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); + const other = await vscode.workspace.openTextDocument(fixtureUri('src/sample-b.ts')); + await vscode.window.showTextDocument(other); + await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); + const [matchingBookmark] = store + .getAllBookmarks() + .filter((b) => b.relativePath === 'src/sample-a.ts'); + + provider.setSearchFilter('sample-a'); + const filtered = provider.getChildren() as BookmarkTreeItem[]; + assert.equal(filtered.length, 1); + assert.equal(filtered[0].bookmark.id, matchingBookmark.id); + + provider.setSearchFilter(null); + assert.equal((provider.getChildren() as BookmarkTreeItem[]).length, 2); + }); + + it('opens a live filter input box via the filterBookmarks command', async () => { + const { provider } = await getApi(); + + const inputBox = await vscode.commands.executeCommand( + 'workspace-file-bookmarks.filterBookmarks', + ); + + assert.ok(inputBox, 'expected the filterBookmarks command to return the created input box'); + inputBox.hide(); + provider.setSearchFilter(null); + }); + + it('nests a subfolder under another folder, recurses through the real tree, and reparents via drag-and-drop', async () => { + const { store, provider } = await getApi(); + const parent = store.createFolder('Backend'); + const child = store.createFolder('Auth Service', parent.id); + const document = await vscode.workspace.openTextDocument(fixtureUri('src/sample-a.ts')); + await vscode.window.showTextDocument(document); + await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); + const [bookmarkItem] = ( + provider.getChildren() as (BookmarkTreeItem | FolderGroupItem)[] + ).filter(isBookmarkNode); + store.moveBookmarkToFolder(bookmarkItem.bookmark.id, child.id); + + const [parentNode] = provider.getChildren() as FolderGroupItem[]; + assert.equal(parentNode.folder.id, parent.id); + const [childNode] = provider.getChildren(parentNode) as FolderGroupItem[]; + assert.equal(childNode.folder.id, child.id); + const grandchildren = provider.getChildren(childNode) as BookmarkTreeItem[]; + assert.equal(grandchildren.length, 1); + assert.equal(grandchildren[0].bookmark.relativePath, 'src/sample-a.ts'); + + // Reparent the child back to the root via the real drag-and-drop handler. + const dataTransfer = new vscode.DataTransfer(); + provider.handleDrag([childNode], dataTransfer); + await provider.handleDrop(undefined, dataTransfer); + assert.equal(store.getAllFolders().find((f) => f.id === child.id)?.parentId, null); + }); }); diff --git a/test/fakeContext.ts b/test/fakeContext.ts index 2acc47c..3237d28 100644 --- a/test/fakeContext.ts +++ b/test/fakeContext.ts @@ -2,16 +2,16 @@ // workspaceState.get/update backed by a plain in-memory map. export function createFakeContext() { - const state = new Map(); - return { - workspaceState: { - get(key: string, defaultValue: T): T { - return state.has(key) ? (state.get(key) as T) : defaultValue; - }, - update(key: string, value: unknown) { - state.set(key, value); - return Promise.resolve(); - } - } - }; + const state = new Map(); + return { + workspaceState: { + get(key: string, defaultValue: T): T { + return state.has(key) ? (state.get(key) as T) : defaultValue; + }, + update(key: string, value: unknown) { + state.set(key, value); + return Promise.resolve(); + }, + }, + }; } diff --git a/test/setup.ts b/test/setup.ts index 9dcffcc..25e5bed 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -2,7 +2,7 @@ import { afterEach, vi } from 'vitest'; import { window, workspace } from './vscode-mock'; afterEach(() => { - vi.clearAllMocks(); - window.activeTextEditor = undefined; - workspace.workspaceFolders = undefined; + vi.clearAllMocks(); + window.activeTextEditor = undefined; + workspace.workspaceFolders = undefined; }); diff --git a/test/toBookmark.test.ts b/test/toBookmark.test.ts index f635b2a..4758e17 100644 --- a/test/toBookmark.test.ts +++ b/test/toBookmark.test.ts @@ -3,28 +3,28 @@ import { toBookmark } from '../extension'; import { Uri, workspace } from './vscode-mock'; describe('toBookmark', () => { - it('uses the workspace-relative path and folder name inside a workspace folder', () => { - const uri = Uri.file('/repo/src/a.ts'); - workspace.getWorkspaceFolder.mockReturnValue({ name: 'repo', uri: Uri.file('/repo') }); - workspace.asRelativePath.mockReturnValue('src/a.ts'); + it('uses the workspace-relative path and folder name inside a workspace folder', () => { + const uri = Uri.file('/repo/src/a.ts'); + workspace.getWorkspaceFolder.mockReturnValue({ name: 'repo', uri: Uri.file('/repo') }); + workspace.asRelativePath.mockReturnValue('src/a.ts'); - const bookmark = toBookmark(uri as any); + const bookmark = toBookmark(uri as any); - expect(bookmark.relativePath).toBe('src/a.ts'); - expect(bookmark.workspaceFolderName).toBe('repo'); - expect(bookmark.label).toBe('a.ts'); - expect(bookmark.folderId).toBeNull(); - expect(bookmark.uri).toBe(uri.toString()); - }); + expect(bookmark.relativePath).toBe('src/a.ts'); + expect(bookmark.workspaceFolderName).toBe('repo'); + expect(bookmark.label).toBe('a.ts'); + expect(bookmark.folderId).toBeNull(); + expect(bookmark.uri).toBe(uri.toString()); + }); - it('falls back to the raw fsPath and "Workspace" when outside any workspace folder', () => { - const uri = Uri.file('/outside/b.ts'); - workspace.getWorkspaceFolder.mockReturnValue(undefined); + it('falls back to the raw fsPath and "Workspace" when outside any workspace folder', () => { + const uri = Uri.file('/outside/b.ts'); + workspace.getWorkspaceFolder.mockReturnValue(undefined); - const bookmark = toBookmark(uri as any); + const bookmark = toBookmark(uri as any); - expect(bookmark.relativePath).toBe('/outside/b.ts'); - expect(bookmark.workspaceFolderName).toBe('Workspace'); - expect(bookmark.label).toBe('b.ts'); - }); + expect(bookmark.relativePath).toBe('/outside/b.ts'); + expect(bookmark.workspaceFolderName).toBe('Workspace'); + expect(bookmark.label).toBe('b.ts'); + }); }); diff --git a/test/treeProvider.test.ts b/test/treeProvider.test.ts index 3913a13..58d7a4f 100644 --- a/test/treeProvider.test.ts +++ b/test/treeProvider.test.ts @@ -1,229 +1,343 @@ import { describe, it, expect } from 'vitest'; -import { BookmarkStore, BookmarksTreeProvider, FolderGroupItem, BookmarkTreeItem, type Bookmark } from '../extension'; +import { + BookmarkStore, + BookmarksTreeProvider, + FolderGroupItem, + BookmarkTreeItem, + type Bookmark, +} from '../extension'; import { createFakeContext } from './fakeContext'; import { commands, workspace, Uri } from './vscode-mock'; function makeBookmark(overrides: Partial = {}): Bookmark { - const id = overrides.id ?? 'id-1'; - return { - id, - uri: `file:///repo/src/${id}.ts`, - label: `${id}.ts`, - relativePath: `src/${id}.ts`, - workspaceFolderName: 'repo', - folderId: null, - createdAt: 0, - ...overrides - }; + const id = overrides.id ?? 'id-1'; + return { + id, + uri: `file:///repo/src/${id}.ts`, + label: `${id}.ts`, + relativePath: `src/${id}.ts`, + workspaceFolderName: 'repo', + folderId: null, + createdAt: 0, + ...overrides, + }; } function newProvider() { - const context = createFakeContext(); - const store = new BookmarkStore(context as any); - const provider = new BookmarksTreeProvider(store, context as any); - return { store, provider }; + const context = createFakeContext(); + const store = new BookmarkStore(context as any); + const provider = new BookmarksTreeProvider(store, context as any); + return { store, provider }; } describe('BookmarksTreeProvider', () => { - it('defaults to tree view mode', () => { - const { provider } = newProvider(); - expect(provider.getViewMode()).toBe('tree'); + it('defaults to tree view mode', () => { + const { provider } = newProvider(); + expect(provider.getViewMode()).toBe('tree'); + }); + + it('switches view mode and persists it', () => { + const { provider } = newProvider(); + provider.setViewMode('list'); + expect(provider.getViewMode()).toBe('list'); + }); + + it('getTreeItem returns the element unchanged', () => { + const { provider } = newProvider(); + const item = new BookmarkTreeItem(makeBookmark(), ['src/a.ts']); + expect(provider.getTreeItem(item)).toBe(item); + }); + + describe('tag filter', () => { + it('defaults to no filter', () => { + const { provider } = newProvider(); + expect(provider.getTagFilter()).toBeNull(); }); - it('switches view mode and persists it', () => { - const { provider } = newProvider(); + it('sets and reports the active tag filter, updating the context key', () => { + const { provider } = newProvider(); + provider.setTagFilter('auth'); + expect(provider.getTagFilter()).toBe('auth'); + expect(commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + 'workspace-file-bookmarks.tagFilterActive', + true, + ); + }); + + it('clears the filter and updates the context key', () => { + const { provider } = newProvider(); + provider.setTagFilter('auth'); + provider.setTagFilter(null); + expect(provider.getTagFilter()).toBeNull(); + expect(commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + 'workspace-file-bookmarks.tagFilterActive', + false, + ); + }); + + it('narrows list mode to bookmarks carrying the active tag', () => { + const { store, provider } = newProvider(); + provider.setViewMode('list'); + store.addBookmark(makeBookmark({ id: 'tagged', tags: ['auth'] })); + store.addBookmark(makeBookmark({ id: 'untagged' })); + + provider.setTagFilter('auth'); + const children = provider.getChildren() as BookmarkTreeItem[]; + + expect(children.map((c) => c.bookmark.id)).toEqual(['tagged']); + }); + + it('narrows tree mode, including bookmarks nested in folders', () => { + const { store, provider } = newProvider(); + const folder = store.createFolder('Backend'); + store.addBookmark(makeBookmark({ id: 'tagged', folderId: folder.id, tags: ['auth'] })); + store.addBookmark(makeBookmark({ id: 'untagged', folderId: folder.id })); + + provider.setTagFilter('auth'); + const [folderNode] = provider.getChildren(); + const children = provider.getChildren(folderNode) as BookmarkTreeItem[]; + + expect(children.map((c) => c.bookmark.id)).toEqual(['tagged']); + }); + }); + + describe('search filter', () => { + it('defaults to no filter', () => { + const { provider } = newProvider(); + expect(provider.getSearchFilter()).toBeNull(); + }); + + it('sets and reports the active search filter, updating the context key', () => { + const { provider } = newProvider(); + provider.setSearchFilter('auth'); + expect(provider.getSearchFilter()).toBe('auth'); + expect(commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + 'workspace-file-bookmarks.searchFilterActive', + true, + ); + }); + + it('treats an empty or blank string as clearing the filter', () => { + const { provider } = newProvider(); + provider.setSearchFilter('auth'); + provider.setSearchFilter(' '); + expect(provider.getSearchFilter()).toBeNull(); + expect(commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + 'workspace-file-bookmarks.searchFilterActive', + false, + ); + }); + + it('narrows list mode by label, relative path, or repo name', () => { + const { store, provider } = newProvider(); + provider.setViewMode('list'); + store.addBookmark(makeBookmark({ id: 'match', label: 'auth-service.ts' })); + store.addBookmark(makeBookmark({ id: 'no-match', label: 'other.ts' })); + + provider.setSearchFilter('auth'); + const children = provider.getChildren() as BookmarkTreeItem[]; + + expect(children.map((c) => c.bookmark.id)).toEqual(['match']); + }); + + it('narrows tree mode, including bookmarks nested in folders', () => { + const { store, provider } = newProvider(); + const folder = store.createFolder('Backend'); + store.addBookmark( + makeBookmark({ id: 'match', folderId: folder.id, label: 'auth-service.ts' }), + ); + store.addBookmark(makeBookmark({ id: 'no-match', folderId: folder.id, label: 'other.ts' })); + + provider.setSearchFilter('auth'); + const [folderNode] = provider.getChildren(); + const children = provider.getChildren(folderNode) as BookmarkTreeItem[]; + + expect(children.map((c) => c.bookmark.id)).toEqual(['match']); + }); + + it('combines with an active tag filter', () => { + const { store, provider } = newProvider(); + store.addBookmark(makeBookmark({ id: 'both', label: 'auth-service.ts', tags: ['review'] })); + store.addBookmark(makeBookmark({ id: 'tag-only', label: 'other.ts', tags: ['review'] })); + store.addBookmark(makeBookmark({ id: 'search-only', label: 'auth-utils.ts' })); + + provider.setTagFilter('review'); + provider.setSearchFilter('auth'); + const children = provider.getChildren() as BookmarkTreeItem[]; + + expect(children.map((c) => c.bookmark.id)).toEqual(['both']); + }); + }); + + describe('list mode', () => { + it('returns all bookmarks newest-first, ignoring folders', () => { + const { store, provider } = newProvider(); + provider.setViewMode('list'); + store.createFolder('Backend'); + store.addBookmark(makeBookmark({ id: 'old', createdAt: 1 })); + store.addBookmark(makeBookmark({ id: 'new', createdAt: 2 })); + + const children = provider.getChildren() as BookmarkTreeItem[]; + + expect(children.map((c) => c.bookmark.id)).toEqual(['new', 'old']); + }); + + it('returns no children for any element (flat list)', () => { + const { provider } = newProvider(); + provider.setViewMode('list'); + const item = new BookmarkTreeItem(makeBookmark(), []); + + expect(provider.getChildren(item)).toEqual([]); + }); + }); + + describe('tree mode', () => { + it('groups bookmarks under their folder, alphabetized, and lists ungrouped ones after', () => { + const { store, provider } = newProvider(); + const zFolder = store.createFolder('Zeta'); + const aFolder = store.createFolder('Alpha'); + store.addBookmark(makeBookmark({ id: 'grouped', folderId: aFolder.id })); + store.addBookmark(makeBookmark({ id: 'ungrouped-old', folderId: null, createdAt: 1 })); + store.addBookmark(makeBookmark({ id: 'ungrouped-new', folderId: null, createdAt: 2 })); + + const children = provider.getChildren(); + + expect( + children + .filter((c): c is FolderGroupItem => c instanceof FolderGroupItem) + .map((f) => f.folder.name), + ).toEqual(['Alpha', 'Zeta']); + expect( + children + .filter((c): c is BookmarkTreeItem => c instanceof BookmarkTreeItem) + .map((b) => b.bookmark.id), + ).toEqual(['ungrouped-new', 'ungrouped-old']); + void zFolder; + }); + + it('returns the bookmarks inside a folder, newest first, when expanded', () => { + const { store, provider } = newProvider(); + const folder = store.createFolder('Backend'); + store.addBookmark(makeBookmark({ id: 'old', folderId: folder.id, createdAt: 1 })); + store.addBookmark(makeBookmark({ id: 'new', folderId: folder.id, createdAt: 2 })); + + const [folderNode] = provider.getChildren(); + const children = provider.getChildren(folderNode) as BookmarkTreeItem[]; + + expect(children.map((c) => c.bookmark.id)).toEqual(['new', 'old']); + }); + + it('returns no children for a leaf bookmark node', () => { + const { store, provider } = newProvider(); + store.addBookmark(makeBookmark()); + const [bookmarkNode] = provider.getChildren(); + + expect(provider.getChildren(bookmarkNode)).toEqual([]); + }); + + it('includes the workspace folder name in a multi-root workspace', () => { + const { store, provider } = newProvider(); + workspace.workspaceFolders = [ + { name: 'repo-a', uri: Uri.file('/repo-a') }, + { name: 'repo-b', uri: Uri.file('/repo-b') }, + ]; + store.addBookmark(makeBookmark()); + + const [bookmarkNode] = provider.getChildren() as BookmarkTreeItem[]; + + expect(bookmarkNode.description).toContain('repo'); + }); + + describe('nested folders', () => { + it('shows only root-level folders at the top level', () => { + const { store, provider } = newProvider(); + const parent = store.createFolder('Backend'); + store.createFolder('Auth Service', parent.id); + + const children = provider.getChildren(); + + expect( + children + .filter((c): c is FolderGroupItem => c instanceof FolderGroupItem) + .map((f) => f.folder.name), + ).toEqual(['Backend']); + }); + + it('lists child folders (alphabetized) before direct bookmarks when a folder is expanded', () => { + const { store, provider } = newProvider(); + const parent = store.createFolder('Backend'); + store.createFolder('Zeta Service', parent.id); + store.createFolder('Alpha Service', parent.id); + store.addBookmark(makeBookmark({ id: 'direct', folderId: parent.id })); + + const [parentNode] = provider.getChildren(); + const children = provider.getChildren(parentNode); + + expect( + children.map((c) => + c instanceof FolderGroupItem ? c.folder.name : (c as BookmarkTreeItem).bookmark.id, + ), + ).toEqual(['Alpha Service', 'Zeta Service', 'direct']); + }); + + it('recurses through multiple levels of nesting', () => { + const { store, provider } = newProvider(); + const l1 = store.createFolder('L1'); + const l2 = store.createFolder('L2', l1.id); + store.addBookmark(makeBookmark({ id: 'deep', folderId: l2.id })); + + const [l1Node] = provider.getChildren(); + const [l2Node] = provider.getChildren(l1Node) as FolderGroupItem[]; + const l2Children = provider.getChildren(l2Node) as BookmarkTreeItem[]; + + expect(l2Node.folder.id).toBe(l2.id); + expect(l2Children.map((c) => c.bookmark.id)).toEqual(['deep']); + }); + + it('shows a subfolder count in the description alongside the bookmark count', () => { + const { store } = newProvider(); + const parent = store.createFolder('Backend'); + store.createFolder('Auth Service', parent.id); + store.addBookmark(makeBookmark({ folderId: parent.id })); + + const node = new FolderGroupItem( + parent, + [makeBookmark({ folderId: parent.id })], + [{ id: 'child', name: 'Auth Service', createdAt: 0, parentId: parent.id }], + ); + + expect(node.description).toBe('1 • 1 subfolder'); + }); + + it('uses the plural "subfolders" when there is more than one', () => { + const parent = { id: 'p', name: 'Backend', createdAt: 0, parentId: null }; + const node = new FolderGroupItem( + parent, + [], + [ + { id: 'c1', name: 'A', createdAt: 0, parentId: 'p' }, + { id: 'c2', name: 'B', createdAt: 0, parentId: 'p' }, + ], + ); + + expect(node.description).toBe('0 • 2 subfolders'); + }); + + it('shows a breadcrumb path for a nested folder in list mode', () => { + const { store, provider } = newProvider(); provider.setViewMode('list'); - expect(provider.getViewMode()).toBe('list'); - }); - - it('getTreeItem returns the element unchanged', () => { - const { provider } = newProvider(); - const item = new BookmarkTreeItem(makeBookmark(), ['src/a.ts']); - expect(provider.getTreeItem(item)).toBe(item); - }); - - describe('tag filter', () => { - it('defaults to no filter', () => { - const { provider } = newProvider(); - expect(provider.getTagFilter()).toBeNull(); - }); - - it('sets and reports the active tag filter, updating the context key', () => { - const { provider } = newProvider(); - provider.setTagFilter('auth'); - expect(provider.getTagFilter()).toBe('auth'); - expect(commands.executeCommand).toHaveBeenCalledWith('setContext', 'workspace-file-bookmarks.tagFilterActive', true); - }); - - it('clears the filter and updates the context key', () => { - const { provider } = newProvider(); - provider.setTagFilter('auth'); - provider.setTagFilter(null); - expect(provider.getTagFilter()).toBeNull(); - expect(commands.executeCommand).toHaveBeenCalledWith('setContext', 'workspace-file-bookmarks.tagFilterActive', false); - }); - - it('narrows list mode to bookmarks carrying the active tag', () => { - const { store, provider } = newProvider(); - provider.setViewMode('list'); - store.addBookmark(makeBookmark({ id: 'tagged', tags: ['auth'] })); - store.addBookmark(makeBookmark({ id: 'untagged' })); - - provider.setTagFilter('auth'); - const children = provider.getChildren() as BookmarkTreeItem[]; - - expect(children.map(c => c.bookmark.id)).toEqual(['tagged']); - }); - - it('narrows tree mode, including bookmarks nested in folders', () => { - const { store, provider } = newProvider(); - const folder = store.createFolder('Backend'); - store.addBookmark(makeBookmark({ id: 'tagged', folderId: folder.id, tags: ['auth'] })); - store.addBookmark(makeBookmark({ id: 'untagged', folderId: folder.id })); - - provider.setTagFilter('auth'); - const [folderNode] = provider.getChildren(); - const children = provider.getChildren(folderNode) as BookmarkTreeItem[]; - - expect(children.map(c => c.bookmark.id)).toEqual(['tagged']); - }); - }); - - describe('search filter', () => { - it('defaults to no filter', () => { - const { provider } = newProvider(); - expect(provider.getSearchFilter()).toBeNull(); - }); - - it('sets and reports the active search filter, updating the context key', () => { - const { provider } = newProvider(); - provider.setSearchFilter('auth'); - expect(provider.getSearchFilter()).toBe('auth'); - expect(commands.executeCommand).toHaveBeenCalledWith('setContext', 'workspace-file-bookmarks.searchFilterActive', true); - }); - - it('treats an empty or blank string as clearing the filter', () => { - const { provider } = newProvider(); - provider.setSearchFilter('auth'); - provider.setSearchFilter(' '); - expect(provider.getSearchFilter()).toBeNull(); - expect(commands.executeCommand).toHaveBeenCalledWith('setContext', 'workspace-file-bookmarks.searchFilterActive', false); - }); - - it('narrows list mode by label, relative path, or repo name', () => { - const { store, provider } = newProvider(); - provider.setViewMode('list'); - store.addBookmark(makeBookmark({ id: 'match', label: 'auth-service.ts' })); - store.addBookmark(makeBookmark({ id: 'no-match', label: 'other.ts' })); - - provider.setSearchFilter('auth'); - const children = provider.getChildren() as BookmarkTreeItem[]; - - expect(children.map(c => c.bookmark.id)).toEqual(['match']); - }); - - it('narrows tree mode, including bookmarks nested in folders', () => { - const { store, provider } = newProvider(); - const folder = store.createFolder('Backend'); - store.addBookmark(makeBookmark({ id: 'match', folderId: folder.id, label: 'auth-service.ts' })); - store.addBookmark(makeBookmark({ id: 'no-match', folderId: folder.id, label: 'other.ts' })); - - provider.setSearchFilter('auth'); - const [folderNode] = provider.getChildren(); - const children = provider.getChildren(folderNode) as BookmarkTreeItem[]; - - expect(children.map(c => c.bookmark.id)).toEqual(['match']); - }); - - it('combines with an active tag filter', () => { - const { store, provider } = newProvider(); - store.addBookmark(makeBookmark({ id: 'both', label: 'auth-service.ts', tags: ['review'] })); - store.addBookmark(makeBookmark({ id: 'tag-only', label: 'other.ts', tags: ['review'] })); - store.addBookmark(makeBookmark({ id: 'search-only', label: 'auth-utils.ts' })); - - provider.setTagFilter('review'); - provider.setSearchFilter('auth'); - const children = provider.getChildren() as BookmarkTreeItem[]; - - expect(children.map(c => c.bookmark.id)).toEqual(['both']); - }); - }); - - describe('list mode', () => { - it('returns all bookmarks newest-first, ignoring folders', () => { - const { store, provider } = newProvider(); - provider.setViewMode('list'); - store.createFolder('Backend'); - store.addBookmark(makeBookmark({ id: 'old', createdAt: 1 })); - store.addBookmark(makeBookmark({ id: 'new', createdAt: 2 })); - - const children = provider.getChildren() as BookmarkTreeItem[]; - - expect(children.map(c => c.bookmark.id)).toEqual(['new', 'old']); - }); - - it('returns no children for any element (flat list)', () => { - const { provider } = newProvider(); - provider.setViewMode('list'); - const item = new BookmarkTreeItem(makeBookmark(), []); - - expect(provider.getChildren(item)).toEqual([]); - }); - }); - - describe('tree mode', () => { - it('groups bookmarks under their folder, alphabetized, and lists ungrouped ones after', () => { - const { store, provider } = newProvider(); - const zFolder = store.createFolder('Zeta'); - const aFolder = store.createFolder('Alpha'); - store.addBookmark(makeBookmark({ id: 'grouped', folderId: aFolder.id })); - store.addBookmark(makeBookmark({ id: 'ungrouped-old', folderId: null, createdAt: 1 })); - store.addBookmark(makeBookmark({ id: 'ungrouped-new', folderId: null, createdAt: 2 })); - - const children = provider.getChildren(); - - expect(children.filter((c): c is FolderGroupItem => c instanceof FolderGroupItem).map(f => f.folder.name)).toEqual([ - 'Alpha', - 'Zeta' - ]); - expect(children.filter((c): c is BookmarkTreeItem => c instanceof BookmarkTreeItem).map(b => b.bookmark.id)).toEqual([ - 'ungrouped-new', - 'ungrouped-old' - ]); - void zFolder; - }); - - it('returns the bookmarks inside a folder, newest first, when expanded', () => { - const { store, provider } = newProvider(); - const folder = store.createFolder('Backend'); - store.addBookmark(makeBookmark({ id: 'old', folderId: folder.id, createdAt: 1 })); - store.addBookmark(makeBookmark({ id: 'new', folderId: folder.id, createdAt: 2 })); - - const [folderNode] = provider.getChildren(); - const children = provider.getChildren(folderNode) as BookmarkTreeItem[]; - - expect(children.map(c => c.bookmark.id)).toEqual(['new', 'old']); - }); - - it('returns no children for a leaf bookmark node', () => { - const { store, provider } = newProvider(); - store.addBookmark(makeBookmark()); - const [bookmarkNode] = provider.getChildren(); - - expect(provider.getChildren(bookmarkNode)).toEqual([]); - }); - - it('includes the workspace folder name in a multi-root workspace', () => { - const { store, provider } = newProvider(); - workspace.workspaceFolders = [ - { name: 'repo-a', uri: Uri.file('/repo-a') }, - { name: 'repo-b', uri: Uri.file('/repo-b') } - ]; - store.addBookmark(makeBookmark()); - - const [bookmarkNode] = provider.getChildren() as BookmarkTreeItem[]; - - expect(bookmarkNode.description).toContain('repo'); - }); + const parent = store.createFolder('Backend'); + const child = store.createFolder('Auth Service', parent.id); + store.addBookmark(makeBookmark({ folderId: child.id })); + + const [bookmarkNode] = provider.getChildren() as BookmarkTreeItem[]; + + expect(bookmarkNode.description).toContain('Backend › Auth Service'); + }); }); + }); }); diff --git a/test/vscode-mock.ts b/test/vscode-mock.ts index 8fb221e..702fc6e 100644 --- a/test/vscode-mock.ts +++ b/test/vscode-mock.ts @@ -4,131 +4,137 @@ import { vi } from 'vitest'; export class EventEmitter { - private listeners: Array<(e: T) => void> = []; + private listeners: Array<(e: T) => void> = []; - event = (listener: (e: T) => void) => { - this.listeners.push(listener); - return { dispose: () => {} }; - }; + event = (listener: (e: T) => void) => { + this.listeners.push(listener); + return { dispose: () => {} }; + }; - fire(data: T) { - for (const listener of this.listeners) { - listener(data); - } + fire(data: T) { + for (const listener of this.listeners) { + listener(data); } + } } export class Uri { - private constructor(public readonly fsPath: string, private readonly raw: string) {} - - static parse(value: string): Uri { - return new Uri(value.replace(/^file:\/\//, ''), value); - } - - static file(path: string): Uri { - return new Uri(path, `file://${path}`); - } - - toString() { - return this.raw; - } + private constructor( + public readonly fsPath: string, + private readonly raw: string, + ) {} + + static parse(value: string): Uri { + return new Uri(value.replace(/^file:\/\//, ''), value); + } + + static file(path: string): Uri { + return new Uri(path, `file://${path}`); + } + + toString() { + return this.raw; + } } export const TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 }; export class TreeItem { - contextValue?: string; - iconPath?: unknown; - description?: string; - tooltip?: string; - command?: unknown; - - constructor(public label?: unknown, public collapsibleState?: unknown) {} + contextValue?: string; + iconPath?: unknown; + description?: string; + tooltip?: string; + command?: unknown; + + constructor( + public label?: unknown, + public collapsibleState?: unknown, + ) {} } export class ThemeIcon { - constructor(public id: string) {} + constructor(public id: string) {} } export class DataTransferItem { - constructor(private readonly value: string) {} + constructor(private readonly value: string) {} - asString(): Promise { - return Promise.resolve(this.value); - } + asString(): Promise { + return Promise.resolve(this.value); + } } export class DataTransfer { - private readonly items = new Map(); + private readonly items = new Map(); - set(mimeType: string, value: DataTransferItem) { - this.items.set(mimeType, value); - } + set(mimeType: string, value: DataTransferItem) { + this.items.set(mimeType, value); + } - get(mimeType: string): DataTransferItem | undefined { - return this.items.get(mimeType); - } + get(mimeType: string): DataTransferItem | undefined { + return this.items.get(mimeType); + } } export class InputBox { - placeholder = ''; - value = ''; - disposed = false; - visible = false; - - private readonly changeEmitter = new EventEmitter(); - private readonly acceptEmitter = new EventEmitter(); - private readonly hideEmitter = new EventEmitter(); - - onDidChangeValue = this.changeEmitter.event; - onDidAccept = this.acceptEmitter.event; - onDidHide = this.hideEmitter.event; - - show() { - this.visible = true; - } - - hide() { - this.visible = false; - this.hideEmitter.fire(); - } - - dispose() { - this.disposed = true; - } - - /** Test helper: simulates the user typing, updating `value` and firing onDidChangeValue. */ - triggerChangeValue(value: string) { - this.value = value; - this.changeEmitter.fire(value); - } - - /** Test helper: simulates pressing Enter. */ - triggerAccept() { - this.acceptEmitter.fire(); - } + placeholder = ''; + value = ''; + disposed = false; + visible = false; + + private readonly changeEmitter = new EventEmitter(); + private readonly acceptEmitter = new EventEmitter(); + private readonly hideEmitter = new EventEmitter(); + + onDidChangeValue = this.changeEmitter.event; + onDidAccept = this.acceptEmitter.event; + onDidHide = this.hideEmitter.event; + + show() { + this.visible = true; + } + + hide() { + this.visible = false; + this.hideEmitter.fire(); + } + + dispose() { + this.disposed = true; + } + + /** Test helper: simulates the user typing, updating `value` and firing onDidChangeValue. */ + triggerChangeValue(value: string) { + this.value = value; + this.changeEmitter.fire(value); + } + + /** Test helper: simulates pressing Enter. */ + triggerAccept() { + this.acceptEmitter.fire(); + } } export const window = { - activeTextEditor: undefined as { document: { uri: Uri } } | undefined, - showInformationMessage: vi.fn(), - showWarningMessage: vi.fn(), - showErrorMessage: vi.fn(), - showInputBox: vi.fn(), - showQuickPick: vi.fn(), - showTextDocument: vi.fn(), - createTreeView: vi.fn(() => ({ dispose: () => {} })), - createInputBox: vi.fn(() => new InputBox()) + activeTextEditor: undefined as { document: { uri: Uri } } | undefined, + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + showInputBox: vi.fn(), + showQuickPick: vi.fn(), + showTextDocument: vi.fn(), + createTreeView: vi.fn(() => ({ dispose: () => {} })), + createInputBox: vi.fn(() => new InputBox()), }; export const workspace = { - workspaceFolders: undefined as Array<{ name: string; uri: Uri }> | undefined, - getWorkspaceFolder: vi.fn(), - asRelativePath: vi.fn((uri: Uri) => uri.fsPath), - openTextDocument: vi.fn() + workspaceFolders: undefined as Array<{ name: string; uri: Uri }> | undefined, + getWorkspaceFolder: vi.fn(), + asRelativePath: vi.fn((uri: Uri) => uri.fsPath), + openTextDocument: vi.fn(), }; export const commands = { - registerCommand: vi.fn(() => ({ dispose: () => {} })), - executeCommand: vi.fn() + registerCommand: vi.fn(() => ({ dispose: () => {} })), + executeCommand: vi.fn(), }; diff --git a/vitest.config.mts b/vitest.config.mts index c76f7c3..2e8f5ee 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -2,25 +2,25 @@ import { defineConfig } from 'vitest/config'; import path from 'node:path'; export default defineConfig({ - resolve: { - alias: { - vscode: path.resolve(import.meta.dirname, 'test/vscode-mock.ts') - } + resolve: { + alias: { + vscode: path.resolve(import.meta.dirname, 'test/vscode-mock.ts'), }, - test: { - exclude: ['**/node_modules/**', 'test/e2e/**', 'out-e2e/**', '.vscode-test/**'], - setupFiles: ['./test/setup.ts'], - clearMocks: true, - coverage: { - provider: 'v8', - reporter: ['text', 'lcov'], - include: ['extension.ts'], - thresholds: { - lines: 85, - statements: 85, - functions: 85, - branches: 85 - } - } - } + }, + test: { + exclude: ['**/node_modules/**', 'test/e2e/**', 'out-e2e/**', '.vscode-test/**'], + setupFiles: ['./test/setup.ts'], + clearMocks: true, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['extension.ts'], + thresholds: { + lines: 85, + statements: 85, + functions: 85, + branches: 85, + }, + }, + }, });