Skip to content

Commit fe0d231

Browse files
Copilotwinterdrive
andauthored
Fix removal of reloaded group files when config stores relative paths (#47)
* Initial plan * fix: support removing reloaded relative-path files from groups Agent-Logs-Url: https://github.com/winterdrive/vscode-virtual-tabs/sessions/612e9429-d753-4679-81d6-e00a9edfdf8d Co-authored-by: winterdrive <90021888+winterdrive@users.noreply.github.com> * fix: enhance file removal functionality and add coverage tests * fix: update version to 0.6.0 in package.json and package-lock.json * fix: update changelog for version 0.6.0 and enhance coverage details for group file removal --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: winterdrive <90021888+winterdrive@users.noreply.github.com>
1 parent df9c170 commit fe0d231

19 files changed

Lines changed: 865 additions & 54 deletions

.github/workflows/validate.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ jobs:
5555
- name: Type check
5656
run: tsc -p ./
5757

58-
- name: Run Unit and Property tests
59-
run: npm test
58+
- name: Run Unit and Property tests with coverage
59+
run: npm run test:coverage
6060

6161
- name: Package extension
6262
run: npx @vscode/vsce package --out virtual-tabs.vsix

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@ test-resources/
3636
# Testing
3737
/coverage
3838
test-ui-output.txt
39+
docs/assets/fix_rounded_corners.py

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

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

5-
## [Unreleased]
5+
## [0.6.0] - 2026-05-16
66

77
### Changed
88

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

1313
- Directory drag-and-drop now skips hidden directories whose names start with `.`, while still including dotfiles such as `.gitignore` and `.editorconfig`.
14+
- Removing selected files from a group now works when the group stores workspace-relative file paths after config reload.
15+
16+
### Tests and CI
17+
18+
- 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.
19+
- Added VS Code UI coverage for removing reloaded workspace-relative files from single and separate groups.
20+
- Added `npm run test:coverage` and updated PR validation to run Jest coverage for the issue-critical core helpers before packaging.
1421

1522
## [0.5.5] - 2026-05-13
1623

DEVELOPMENT.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,17 +137,23 @@ VirtualTabs uses three automated test layers:
137137
| Layer | Command | Purpose |
138138
| :--- | :--- | :--- |
139139
| TypeScript + Jest unit tests | `npm run test` | Compiles the extension and runs Jest unit tests. |
140+
| Jest coverage gate | `npm run test:coverage` | Runs Jest with coverage enabled for focused unit-tested core helpers and enforces configured thresholds. |
140141
| Property tests | `npm run test:properties` | Exercises config scope discovery, path routing, and tree aggregation invariants with generated inputs. |
141142
| 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. |
142143

143144
### Run the Full Local Test Set
144145

145146
```bash
146147
npm run test
148+
npm run test:coverage
147149
npm run test:properties
148150
npm run test:ui
149151
```
150152

153+
### Coverage Gate
154+
155+
`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.
156+
151157
### UI/E2E Test Setup
152158

153159
The UI test script compiles the UI test files and then runs them through `extest`:

jest.config.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,21 @@ module.exports = {
77
modulePathIgnorePatterns: ['<rootDir>/.vscode-test/'],
88
transform: {
99
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }]
10+
},
11+
moduleNameMapper: {
12+
'^(\\.{1,2}/.*)\\.js$': '$1'
13+
},
14+
collectCoverageFrom: [
15+
'src/core/FileEntryMatcher.ts',
16+
'src/core/GroupFileRemoval.ts',
17+
'src/core/GroupFileTargets.ts'
18+
],
19+
coverageThreshold: {
20+
global: {
21+
statements: 90,
22+
branches: 80,
23+
functions: 90,
24+
lines: 90
25+
}
1026
}
1127
};

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "virtual-tabs",
33
"displayName": "VirtualTabs - Virtual File Directories & AI context management",
44
"description": "%extension.description%",
5-
"version": "0.5.8",
5+
"version": "0.6.0",
66
"publisher": "winterdrive",
77
"icon": "docs/assets/virtualtabs_icon_128.png",
88
"categories": [
@@ -683,6 +683,7 @@
683683
},
684684
"scripts": {
685685
"test": "tsc -p ./ && jest --runInBand",
686+
"test:coverage": "jest --runInBand --coverage",
686687
"test:properties": "jest --runInBand src/test/properties",
687688
"test:ui:setup": "extest setup-tests",
688689
"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",

src/commands.ts

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { executeWithConfirmation } from './util';
99
import { SkillGenerator } from './mcp/SkillGenerator';
1010
import { McpConfigPanel } from './mcp/McpConfigPanel';
1111
import { SendToManager } from './sendTo';
12+
import { groupItemsByGroupIdx } from './core/GroupFileTargets';
1213

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

637-
// Use the group index from the first file item
638-
const fileItem = filesToRemove[0];
639-
provider.removeFilesFromGroup(fileItem.groupIdx, filesToRemove);
639+
for (const [groupIdx, groupFiles] of filesByGroup) {
640+
provider.removeFilesFromGroup(groupIdx, groupFiles);
641+
}
640642
}));
641643

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

847849

848850
const executeRemove = () => {
849-
let hasChanges = false;
850-
851-
for (const fileItem of filesToRemove) {
852-
if (!(fileItem instanceof TempFileItem)) continue;
853-
854-
const groupIdx = fileItem.groupIdx;
855-
const group = provider.groups[groupIdx];
856-
857-
if (group && group.files) {
858-
const fileUri = fileItem.uri.toString();
859-
const originalLength = group.files.length;
860-
group.files = group.files.filter(uri => uri !== fileUri);
861-
862-
if (group.files.length < originalLength) {
863-
hasChanges = true;
864-
// Remove associated bookmarks
865-
if (group.bookmarks && group.bookmarks[fileUri]) {
866-
delete group.bookmarks[fileUri];
867-
if (Object.keys(group.bookmarks).length === 0) {
868-
delete group.bookmarks;
869-
}
870-
}
871-
}
872-
}
873-
}
851+
const filesByGroup = groupItemsByGroupIdx(
852+
filesToRemove.filter((fileItem): fileItem is TempFileItem => fileItem instanceof TempFileItem)
853+
);
874854

875-
if (hasChanges) {
876-
provider.refresh();
855+
for (const [groupIdx, groupFiles] of filesByGroup) {
856+
provider.removeFilesFromGroup(groupIdx, groupFiles);
877857
}
878858
};
879859

@@ -1822,4 +1802,3 @@ export function registerCommands(
18221802
})
18231803
);
18241804
}
1825-

src/core/FileEntryMatcher.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as path from 'path';
2+
import { fileURLToPath } from 'url';
3+
4+
function normalizeFsPath(fsPath: string): string {
5+
const normalized = path.normalize(fsPath);
6+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
7+
}
8+
9+
function toComparableFsPath(storedEntry: string, scopeRoot?: string): string | undefined {
10+
if (!storedEntry) {
11+
return undefined;
12+
}
13+
14+
if (storedEntry.startsWith('file://')) {
15+
return normalizeFsPath(fileURLToPath(storedEntry));
16+
}
17+
18+
if (path.isAbsolute(storedEntry)) {
19+
return normalizeFsPath(storedEntry);
20+
}
21+
22+
if (!scopeRoot) {
23+
return undefined;
24+
}
25+
26+
return normalizeFsPath(path.resolve(scopeRoot, storedEntry));
27+
}
28+
29+
export function matchesStoredFileEntry(
30+
storedEntry: string,
31+
targetUri: string,
32+
targetFsPath: string,
33+
scopeRoot?: string
34+
): boolean {
35+
if (storedEntry === targetUri) {
36+
return true;
37+
}
38+
39+
const entryFsPath = toComparableFsPath(storedEntry, scopeRoot);
40+
if (!entryFsPath) {
41+
return false;
42+
}
43+
44+
return entryFsPath === normalizeFsPath(targetFsPath);
45+
}

src/core/GroupFileRemoval.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { TempGroup } from '../types';
2+
import { matchesStoredFileEntry } from './FileEntryMatcher';
3+
4+
export interface FileRemovalTarget {
5+
uri: string;
6+
fsPath: string;
7+
}
8+
9+
export function removeStoredFileEntriesFromGroup(
10+
group: Pick<TempGroup, 'files' | 'bookmarks'>,
11+
targets: FileRemovalTarget[],
12+
scopeRoot?: string
13+
): boolean {
14+
if (!group.files || targets.length === 0) {
15+
return false;
16+
}
17+
18+
const originalLength = group.files.length;
19+
group.files = group.files.filter(storedEntry =>
20+
!targets.some(target => matchesStoredFileEntry(storedEntry, target.uri, target.fsPath, scopeRoot))
21+
);
22+
23+
if (group.bookmarks) {
24+
for (const bookmarkKey of Object.keys(group.bookmarks)) {
25+
const shouldDelete = targets.some(target =>
26+
matchesStoredFileEntry(bookmarkKey, target.uri, target.fsPath, scopeRoot)
27+
);
28+
if (shouldDelete) {
29+
delete group.bookmarks[bookmarkKey];
30+
}
31+
}
32+
if (Object.keys(group.bookmarks).length === 0) {
33+
delete group.bookmarks;
34+
}
35+
}
36+
37+
return group.files.length !== originalLength;
38+
}

0 commit comments

Comments
 (0)