Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
71cb406
chore: propagate main into development (#33)
github-actions[bot] Aug 20, 2026
44a13f9
test: add vitest harness covering BookmarkStore and describeBookmark
Wikid82 Aug 20, 2026
da843b9
test: expand coverage to 85%+ and wire up Codecov patch/project gates
Wikid82 Aug 20, 2026
a110081
ci: trigger a run to verify Codecov upload with the new token
Wikid82 Aug 20, 2026
9cb53b3
chore(deps): update codecov/codecov-action action to v7 (#35)
renovate[bot] Aug 20, 2026
481157b
feat(ci): add secret scanning, dependency audit, SBOM and provenance …
Wikid82 Aug 21, 2026
7f0ee6d
fix(scripts): use indexed loop and clarify shellcheck comment
Wikid82 Aug 21, 2026
6b7c474
chore: update default reason for manual dev-to-main promotion
Wikid82 Aug 21, 2026
6988833
chore: shift dev-to-main promotion cron to 12:30 UTC Mondays
Wikid82 Aug 21, 2026
2f35967
chore(deps): update actions/attest-build-provenance digest to 4d10147…
renovate[bot] Aug 21, 2026
12a5336
chore: bump codeql-action/init to latest v4 commit
Wikid82 Aug 21, 2026
ab17af9
chore: bump codeql-action/analyze to latest v4 commit hash
Wikid82 Aug 21, 2026
b026792
feat: rename a bookmark's display label
Wikid82 Aug 21, 2026
663a164
docs: clarify feature branch workflow in CLAUDE.md
Wikid82 Aug 21, 2026
7fc3bb2
Merge branch 'main' into development
Wikid82 Aug 21, 2026
e9e6252
chore: propagate main into development (#58)
github-actions[bot] Aug 21, 2026
b79b759
feat: reorder bookmarks and folders via drag-and-drop
Wikid82 Aug 21, 2026
21e51d8
feat: reorder bookmarks and folders via drag-and-drop (#59)
Wikid82 Aug 21, 2026
c8a2079
feat: add e2e testing with @vscode/test-cli and wire it into CI
Wikid82 Aug 21, 2026
47c2903
Merge remote-tracking branch 'origin/development' into feature/e2e-in…
Wikid82 Aug 21, 2026
fb65195
feat: add e2e testing with @vscode/test-cli and wire it into CI (#60)
Wikid82 Aug 21, 2026
77116f2
Merge remote-tracking branch 'origin/main' into development
Wikid82 Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,23 @@ jobs:

- name: Build
run: npm run build

e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24.19.0
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Install Xvfb
run: sudo apt-get update && sudo apt-get install -y xvfb

- name: Run e2e tests
run: xvfb-run -a npm run test:e2e
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
node_modules/
dist/
coverage/
out-e2e/
.vscode-test/
.DS_Store
*.log
*.vsix
10 changes: 10 additions & 0 deletions .vscode-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +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
}
});
3 changes: 3 additions & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
node_modules/**
scripts/**
test/**
out-e2e/**
.vscode-test/**
extension.ts
extension.ts.map
tsconfig.json
vitest.config.mts
.vscode-test.mjs
codecov.yml
CLAUDE.md
.gitignore
Expand Down
15 changes: 13 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ 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`, and
`npm run build` all pass — see `.github/workflows/ci.yml`.
- **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`.
- **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,
Expand All @@ -33,6 +34,16 @@ Every change is expected to clear this bar before it's mergeable:
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 — real command registration, real tree view, real
`workspaceState`. 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 through
the real `vscode` API. Run locally with `npm run test:e2e` (builds the
extension, bundles `test/e2e/**` with esbuild, then launches the test
host — needs a display or `xvfb-run` on headless Linux).
- **Security scanning clean.** CodeQL runs on every push/PR
(`.github/workflows/codeql.yml`); `npm audit --audit-level=high` (via
`scripts/wfb_dep_update.sh`) gates dependency updates on high/critical
Expand Down
180 changes: 161 additions & 19 deletions extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,70 @@ export interface Bookmark {
workspaceFolderName: string;
folderId: string | null;
createdAt: number;
order?: number;
}

export interface BookmarkFolder {
id: string;
name: string;
createdAt: number;
order?: number;
}

export function activate(context: vscode.ExtensionContext) {
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<T extends { order?: number }>(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<T extends { id: string; order?: 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 bookmarkFallbackCompare = (a: Bookmark, b: Bookmark) => b.createdAt - a.createdAt;
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;
}

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
showCollapseAll: true,
dragAndDropController: provider
});

vscode.commands.executeCommand('setContext', VIEW_MODE_CONTEXT_KEY, provider.getViewMode());
Expand All @@ -50,6 +99,8 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('workspace-file-bookmarks.setViewModeList', () => provider.setViewMode('list')),
vscode.commands.registerCommand('workspace-file-bookmarks.setViewModeTree', () => provider.setViewMode('tree'))
);

return { store, provider };
}

export function deactivate() {}
Expand Down Expand Up @@ -89,6 +140,18 @@ export class BookmarkStore {
this.setBookmarks(this.getAllBookmarks().map(b => (b.id === id ? { ...b, label } : 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): BookmarkFolder {
const folder: BookmarkFolder = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
Expand Down Expand Up @@ -145,7 +208,7 @@ export class BookmarkTreeItem extends vscode.TreeItem {

type TreeNode = FolderGroupItem | BookmarkTreeItem;

export class BookmarksTreeProvider implements vscode.TreeDataProvider<TreeNode> {
export class BookmarksTreeProvider implements vscode.TreeDataProvider<TreeNode>, vscode.TreeDragAndDropController<TreeNode> {
private readonly _onDidChangeTreeData = new vscode.EventEmitter<void>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;

Expand Down Expand Up @@ -181,35 +244,64 @@ export class BookmarksTreeProvider implements vscode.TreeDataProvider<TreeNode>
return [];
}
const folderById = new Map(folders.map(f => [f.id, f.name]));
return bookmarks
.slice()
.sort((a, b) => b.createdAt - a.createdAt)
.map(b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, folderById.get(b.folderId ?? '') ?? null)));
return sortByOrder(bookmarks, bookmarkFallbackCompare).map(
b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, folderById.get(b.folderId ?? '') ?? null))
);
}

if (element instanceof FolderGroupItem) {
return element.bookmarks
.slice()
.sort((a, b) => b.createdAt - a.createdAt)
.map(b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, null)));
return sortByOrder(element.bookmarks, bookmarkFallbackCompare).map(
b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, null))
);
}

if (element) {
return [];
}

const folderNodes = folders
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(folder => new FolderGroupItem(folder, bookmarks.filter(b => b.folderId === folder.id)));
const folderNodes = sortByOrder(folders, folderFallbackCompare).map(
folder => new FolderGroupItem(folder, bookmarks.filter(b => b.folderId === folder.id))
);

const ungrouped = bookmarks
.filter(b => !b.folderId)
.sort((a, b) => b.createdAt - a.createdAt)
.map(b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, null)));
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 dragMimeTypes(): string[] {
return [BOOKMARK_DND_MIME_TYPE];
}

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)));
}

async handleDrop(target: TreeNode | undefined, dataTransfer: vscode.DataTransfer): Promise<void> {
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);
}
}
}

export function describeBookmark(bookmark: Bookmark, isMultiRoot: boolean, folderName: string | null): string[] {
Expand Down Expand Up @@ -356,6 +448,56 @@ export async function pickFolder(store: BookmarkStore, placeHolder: string): Pro
return folders.find(f => f.name === picked)?.id ?? undefined;
}

/** Reorders folders by moving `draggedId` to just before the folder `target` represents (or to the end if dropped elsewhere). */
export function dropFolder(store: BookmarkStore, draggedId: string, target: TreeNode | undefined) {
if (target instanceof BookmarkTreeItem) {
return;
}
const beforeId = target instanceof FolderGroupItem ? target.folder.id : null;
if (beforeId === draggedId) {
return;
}
store.reorderFolders(computeReorderedIds(store.getAllFolders(), draggedId, beforeId, folderFallbackCompare));
}

/**
* Handles a bookmark dropped onto `target`: dropping onto a folder moves the bookmark into it
* (appended at the end); dropping onto another bookmark reorders within that bookmark's scope,
* 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;
}

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) {
Expand Down
Loading