Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ jobs:
- name: Type check
run: tsc -p ./

- name: Run Unit and Property tests
run: npm test
- name: Run Unit and Property tests with coverage
run: npm run test:coverage

- name: Package extension
run: npx @vscode/vsce package --out virtual-tabs.vsix
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ test-resources/
# Testing
/coverage
test-ui-output.txt
docs/assets/fix_rounded_corners.py
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

All notable changes to the "VirtualTabs" extension will be documented in this file.

## [Unreleased]
## [0.6.0] - 2026-05-16

### Changed

Expand All @@ -11,6 +11,13 @@ All notable changes to the "VirtualTabs" extension will be documented in this fi
### Fixed

- Directory drag-and-drop now skips hidden directories whose names start with `.`, while still including dotfiles such as `.gitignore` and `.editorconfig`.
- Removing selected files from a group now works when the group stores workspace-relative file paths after config reload.

### Tests and CI

- Added focused unit coverage for file-entry matching, group file removal, command target grouping, provider-level removal behavior, bookmark cleanup, multi-root scope isolation, and legacy workspace-root fallback.
- Added VS Code UI coverage for removing reloaded workspace-relative files from single and separate groups.
- Added `npm run test:coverage` and updated PR validation to run Jest coverage for the issue-critical core helpers before packaging.

## [0.5.5] - 2026-05-13

Expand Down
6 changes: 6 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,23 @@ VirtualTabs uses three automated test layers:
| Layer | Command | Purpose |
| :--- | :--- | :--- |
| TypeScript + Jest unit tests | `npm run test` | Compiles the extension and runs Jest unit tests. |
| Jest coverage gate | `npm run test:coverage` | Runs Jest with coverage enabled for focused unit-tested core helpers and enforces configured thresholds. |
| Property tests | `npm run test:properties` | Exercises config scope discovery, path routing, and tree aggregation invariants with generated inputs. |
| VS Code UI/E2E tests | `npm run test:ui` | Launches a real VS Code instance with `vscode-extension-tester` and verifies Activity Bar, sidebar, and multi-root scope behavior. |

### Run the Full Local Test Set

```bash
npm run test
npm run test:coverage
npm run test:properties
npm run test:ui
```

### Coverage Gate

`npm run test:coverage` uses Jest coverage and is part of the PR validation workflow. Coverage is intentionally scoped in `jest.config.js`; when adding files to `collectCoverageFrom`, add focused unit tests in the same PR so the gate remains meaningful instead of reflecting unrelated legacy or UI-heavy code.

### UI/E2E Test Setup

The UI test script compiles the UI test files and then runs them through `extest`:
Expand Down
16 changes: 16 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,21 @@ module.exports = {
modulePathIgnorePatterns: ['<rootDir>/.vscode-test/'],
transform: {
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }]
},
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1'
},
collectCoverageFrom: [
'src/core/FileEntryMatcher.ts',
'src/core/GroupFileRemoval.ts',
'src/core/GroupFileTargets.ts'
],
coverageThreshold: {
global: {
statements: 90,
branches: 80,
functions: 90,
lines: 90
}
}
};
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "virtual-tabs",
"displayName": "VirtualTabs - Virtual File Directories & AI context management",
"description": "%extension.description%",
"version": "0.5.8",
"version": "0.6.0",
"publisher": "winterdrive",
"icon": "docs/assets/virtualtabs_icon_128.png",
"categories": [
Expand Down Expand Up @@ -683,6 +683,7 @@
},
"scripts": {
"test": "tsc -p ./ && jest --runInBand",
"test:coverage": "jest --runInBand --coverage",
"test:properties": "jest --runInBand src/test/properties",
"test:ui:setup": "extest setup-tests",
"test:ui": "tsc -p tsconfig.test.ui.json && extest setup-and-run \"out/test/ui/**/*.test.js\" -e \".vscode-test/extensions\" -r \"test-resources/multi-root/virtual-tabs.code-workspace\" -c 1.96.0",
Expand Down
41 changes: 10 additions & 31 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { executeWithConfirmation } from './util';
import { SkillGenerator } from './mcp/SkillGenerator';
import { McpConfigPanel } from './mcp/McpConfigPanel';
import { SendToManager } from './sendTo';
import { groupItemsByGroupIdx } from './core/GroupFileTargets';

// Global clipboard for VirtualTabs items
let globalClipboardItems: (TempFileItem | TempFolderItem)[] = [];
Expand Down Expand Up @@ -633,10 +634,11 @@ export function registerCommands(
context.subscriptions.push(vscode.commands.registerCommand('virtualTabs.removeSelectedFilesFromGroup', (item?: TempFileItem) => {
const filesToRemove = resolveTargetItems(item, provider);
if (filesToRemove.length === 0) return;
const filesByGroup = groupItemsByGroupIdx(filesToRemove);

// Use the group index from the first file item
const fileItem = filesToRemove[0];
provider.removeFilesFromGroup(fileItem.groupIdx, filesToRemove);
for (const [groupIdx, groupFiles] of filesByGroup) {
provider.removeFilesFromGroup(groupIdx, groupFiles);
}
}));

// Group context menu "Add selected files to group"
Expand Down Expand Up @@ -846,34 +848,12 @@ export function registerCommands(


const executeRemove = () => {
let hasChanges = false;

for (const fileItem of filesToRemove) {
if (!(fileItem instanceof TempFileItem)) continue;

const groupIdx = fileItem.groupIdx;
const group = provider.groups[groupIdx];

if (group && group.files) {
const fileUri = fileItem.uri.toString();
const originalLength = group.files.length;
group.files = group.files.filter(uri => uri !== fileUri);

if (group.files.length < originalLength) {
hasChanges = true;
// Remove associated bookmarks
if (group.bookmarks && group.bookmarks[fileUri]) {
delete group.bookmarks[fileUri];
if (Object.keys(group.bookmarks).length === 0) {
delete group.bookmarks;
}
}
}
}
}
const filesByGroup = groupItemsByGroupIdx(
filesToRemove.filter((fileItem): fileItem is TempFileItem => fileItem instanceof TempFileItem)
);

if (hasChanges) {
provider.refresh();
for (const [groupIdx, groupFiles] of filesByGroup) {
provider.removeFilesFromGroup(groupIdx, groupFiles);
}
};

Expand Down Expand Up @@ -1822,4 +1802,3 @@ export function registerCommands(
})
);
}

45 changes: 45 additions & 0 deletions src/core/FileEntryMatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as path from 'path';
import { fileURLToPath } from 'url';

function normalizeFsPath(fsPath: string): string {
const normalized = path.normalize(fsPath);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}

function toComparableFsPath(storedEntry: string, scopeRoot?: string): string | undefined {
if (!storedEntry) {
return undefined;
}

if (storedEntry.startsWith('file://')) {
return normalizeFsPath(fileURLToPath(storedEntry));
}

if (path.isAbsolute(storedEntry)) {
return normalizeFsPath(storedEntry);
}

if (!scopeRoot) {
return undefined;
}

return normalizeFsPath(path.resolve(scopeRoot, storedEntry));
}

export function matchesStoredFileEntry(
storedEntry: string,
targetUri: string,
targetFsPath: string,
scopeRoot?: string
): boolean {
if (storedEntry === targetUri) {
return true;
}

const entryFsPath = toComparableFsPath(storedEntry, scopeRoot);
if (!entryFsPath) {
return false;
}

return entryFsPath === normalizeFsPath(targetFsPath);
}
38 changes: 38 additions & 0 deletions src/core/GroupFileRemoval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { TempGroup } from '../types';
import { matchesStoredFileEntry } from './FileEntryMatcher';

export interface FileRemovalTarget {
uri: string;
fsPath: string;
}

export function removeStoredFileEntriesFromGroup(
group: Pick<TempGroup, 'files' | 'bookmarks'>,
targets: FileRemovalTarget[],
scopeRoot?: string
): boolean {
if (!group.files || targets.length === 0) {
return false;
}

const originalLength = group.files.length;
group.files = group.files.filter(storedEntry =>
!targets.some(target => matchesStoredFileEntry(storedEntry, target.uri, target.fsPath, scopeRoot))
);

if (group.bookmarks) {
for (const bookmarkKey of Object.keys(group.bookmarks)) {
const shouldDelete = targets.some(target =>
matchesStoredFileEntry(bookmarkKey, target.uri, target.fsPath, scopeRoot)
);
if (shouldDelete) {
delete group.bookmarks[bookmarkKey];
}
}
if (Object.keys(group.bookmarks).length === 0) {
delete group.bookmarks;
}
}

return group.files.length !== originalLength;
}
18 changes: 18 additions & 0 deletions src/core/GroupFileTargets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export interface GroupIndexedItem {
groupIdx: number;
}

export function groupItemsByGroupIdx<T extends GroupIndexedItem>(items: T[]): Map<number, T[]> {
const itemsByGroup = new Map<number, T[]>();

for (const item of items) {
const current = itemsByGroup.get(item.groupIdx);
if (current) {
current.push(item);
} else {
itemsByGroup.set(item.groupIdx, [item]);
}
}

return itemsByGroup;
}
18 changes: 11 additions & 7 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { BookmarkManager } from './core/BookmarkManager';
import { GroupManager, OptimisticLockError } from './core/GroupManager';
import { PathUtils } from './core/PathUtils';
import { ConfigScopeDiscovery } from './core/ConfigScopeDiscovery';
import { removeStoredFileEntriesFromGroup } from './core/GroupFileRemoval';

export const BUILTIN_SCOPE_ID = '__builtin__';

Expand Down Expand Up @@ -1326,15 +1327,18 @@ export class TempFoldersProvider implements vscode.TreeDataProvider<vscode.TreeI
removeFilesFromGroup(groupIdx: number, fileItems: TempFileItem[]) {
const group = this.groups[groupIdx];
if (!group || !group.files || fileItems.length === 0) return;
const scopeRoot = group.sourceScopeId
? this.configScopes.find(scope => scope.id === group.sourceScopeId)?.uri.fsPath
: this.getWorkspaceRootPath();

// Ensure all selected files belong to the specified group
const uriStrings = fileItems.map(item => item.uri.toString());

// Remove files from the specified group
group.files = group.files.filter(uriStr => !uriStrings.includes(uriStr));

const targets = fileItems.map(item => ({
uri: item.uri.toString(),
fsPath: item.uri.fsPath
}));

this.refresh();
if (removeStoredFileEntriesFromGroup(group, targets, scopeRoot)) {
this.refresh();
}
}

// Add multiple selected files to a specified group
Expand Down
27 changes: 18 additions & 9 deletions src/test/ui/builtInGroup.ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,14 @@ async function getVisibleTreeLabels(): Promise<string[]> {
async function openVirtualTabsView(): Promise<SideBarView> {
await dismissOnboardingOverlay();
const activityBar = new ActivityBar();
const viewControl = (await activityBar.getViewControl('Virtual Tabs')) as ViewControl;
const viewControl = await VSBrowser.instance.driver.wait(async () => {
await dismissOnboardingOverlay();
return await activityBar.getViewControl('Virtual Tabs') as ViewControl | undefined;
}, 30_000, 'Virtual Tabs icon not found in Activity Bar');
expect(viewControl, 'Virtual Tabs icon not found in Activity Bar').to.not.be.undefined;
if (!viewControl) {
throw new Error('Virtual Tabs icon not found in Activity Bar');
}

let sidebar: SideBarView;
try {
Expand Down Expand Up @@ -125,6 +131,12 @@ async function clickToolbarButton(sidebar: SideBarView, titlePattern: RegExp): P
}, 10_000, `Toolbar button matching "${titlePattern}" not found`);
}

async function reloadVirtualTabsView(): Promise<SideBarView> {
const sidebar = await openVirtualTabsView();
await clickToolbarButton(sidebar, /refresh/i);
return sidebar;
}


// ─────────────────────────────────────────────────────────────────────────────

Expand All @@ -151,7 +163,7 @@ describe('Virtual Tabs – Built-in 群組初始化與可見性', function () {
{ id: 'existing-2', name: 'Bug Fixes', files: [] }
]);

await openVirtualTabsView();
await reloadVirtualTabsView();

// 自訂群組可見
await waitForTreeLabel('Feature Group');
Expand All @@ -165,7 +177,7 @@ describe('Virtual Tabs – Built-in 群組初始化與可見性', function () {
writeConfig(repoAConfigPath, []);
writeConfig(repoBConfigPath, []);

await openVirtualTabsView();
await reloadVirtualTabsView();

// 沒有任何自訂群組
await waitForTreeLabelAbsent('Feature Group');
Expand All @@ -180,10 +192,7 @@ describe('Virtual Tabs – Built-in 群組初始化與可見性', function () {
]);
writeConfig(repoBConfigPath, []);

const sidebar = await openVirtualTabsView();

// 用 Refresh 強制從磁碟載入(不依賴 FileSystemWatcher 的非同步時機)
await clickToolbarButton(sidebar, /refresh/i);
const sidebar = await reloadVirtualTabsView();

// 第一次 Refresh 後:built-in 群組與自訂群組均應存在
await waitForTreeLabel(/currently open|open files|已開啟|目前開啟/i);
Expand All @@ -203,7 +212,7 @@ describe('Virtual Tabs – Built-in 群組初始化與可見性', function () {
writeConfig(repoAConfigPath, repoAOriginal);
writeConfig(repoBConfigPath, repoBOriginal);

await openVirtualTabsView();
await reloadVirtualTabsView();

// built-in 群組在最頂部
await waitForTreeLabel(/currently open|open files|已開啟|目前開啟/i);
Expand All @@ -221,7 +230,7 @@ describe('Virtual Tabs – Built-in 群組初始化與可見性', function () {
writeConfig(repoAConfigPath, repoAOriginal);
writeConfig(repoBConfigPath, repoBOriginal);

await openVirtualTabsView();
await reloadVirtualTabsView();

const labels = await getVisibleTreeLabels();

Expand Down
Loading
Loading