From a6c22bd1b88a718dff548d7940e35d89a667a655 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 05:06:09 +0000 Subject: [PATCH 1/5] Initial plan From 5a95d805764243fdb5b6d0679e59d8c0edc770cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 05:11:09 +0000 Subject: [PATCH 2/5] 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> --- src/commands.ts | 47 ++--- src/core/FileEntryMatcher.ts | 45 +++++ src/provider.ts | 33 +++- .../removeSelectedFilesFromGroup.ui.test.ts | 175 ++++++++++++++++++ src/test/unit/fileEntryMatcher.test.ts | 28 +++ 5 files changed, 296 insertions(+), 32 deletions(-) create mode 100644 src/core/FileEntryMatcher.ts create mode 100644 src/test/ui/removeSelectedFilesFromGroup.ui.test.ts create mode 100644 src/test/unit/fileEntryMatcher.test.ts diff --git a/src/commands.ts b/src/commands.ts index 434b6a9..02c1fe2 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -633,10 +633,19 @@ 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 = new Map(); + for (const fileItem of filesToRemove) { + const current = filesByGroup.get(fileItem.groupIdx); + if (current) { + current.push(fileItem); + } else { + filesByGroup.set(fileItem.groupIdx, [fileItem]); + } + } - // 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" @@ -846,34 +855,19 @@ export function registerCommands( const executeRemove = () => { - let hasChanges = false; - + const filesByGroup = new Map(); 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 current = filesByGroup.get(fileItem.groupIdx); + if (current) { + current.push(fileItem); + } else { + filesByGroup.set(fileItem.groupIdx, [fileItem]); } } - if (hasChanges) { - provider.refresh(); + for (const [groupIdx, groupFiles] of filesByGroup) { + provider.removeFilesFromGroup(groupIdx, groupFiles); } }; @@ -1822,4 +1816,3 @@ export function registerCommands( }) ); } - diff --git a/src/core/FileEntryMatcher.ts b/src/core/FileEntryMatcher.ts new file mode 100644 index 0000000..960ae06 --- /dev/null +++ b/src/core/FileEntryMatcher.ts @@ -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); +} diff --git a/src/provider.ts b/src/provider.ts index 5a72051..b1d81e3 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -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 { matchesStoredFileEntry } from './core/FileEntryMatcher'; export const BUILTIN_SCOPE_ID = '__builtin__'; @@ -1326,15 +1327,37 @@ export class TempFoldersProvider implements vscode.TreeDataProvider 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()); + const targets = fileItems.map(item => ({ + uri: item.uri.toString(), + fsPath: item.uri.fsPath + })); - // Remove files from the specified group - group.files = group.files.filter(uriStr => !uriStrings.includes(uriStr)); + 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; + } + } - this.refresh(); + if (group.files.length !== originalLength) { + this.refresh(); + } } // Add multiple selected files to a specified group diff --git a/src/test/ui/removeSelectedFilesFromGroup.ui.test.ts b/src/test/ui/removeSelectedFilesFromGroup.ui.test.ts new file mode 100644 index 0000000..59cad9c --- /dev/null +++ b/src/test/ui/removeSelectedFilesFromGroup.ui.test.ts @@ -0,0 +1,175 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { + ActivityBar, + CustomTreeSection, + EditorView, + SideBarView, + TreeItem, + ViewControl, + VSBrowser, + Workbench +} from 'vscode-extension-tester'; +import { expect } from 'chai'; + +const fixtureRoot = path.resolve(__dirname, '../../../test-resources/multi-root'); +const repoAPath = path.join(fixtureRoot, 'Repo-A'); +const repoAConfigPath = path.join(repoAPath, '.vscode', 'virtualTab.json'); +const repoAOriginal = [{ id: 'repo-a-existing', name: 'Repo A Existing', files: [] }]; +const regressionFileRelativePath = 'src/remove-selected-file-regression.ts'; +const regressionFileAbsolutePath = path.join(repoAPath, regressionFileRelativePath); + +function writeConfig(configPath: string, groups: object[]): void { + fs.writeFileSync(configPath, `${JSON.stringify(groups, null, 2)}\n`); +} + +function readConfig(configPath: string): Array<{ id?: string; name?: string; files?: string[] }> { + return JSON.parse(fs.readFileSync(configPath, 'utf8')) as Array<{ id?: string; name?: string; files?: string[] }>; +} + +async function dismissOnboardingOverlay(): Promise { + const driver = VSBrowser.instance.driver; + await driver.wait(async () => { + return await driver.executeScript(` + const styleId = 'virtual-tabs-e2e-hide-onboarding-remove'; + if (!document.getElementById(styleId)) { + const style = document.createElement('style'); + style.id = styleId; + style.textContent = '.onboarding-a-overlay { display: none !important; }'; + document.head.appendChild(style); + } + for (const el of document.querySelectorAll('.onboarding-a-overlay, [aria-label="Welcome to Visual Studio Code"][role="dialog"]')) { + el.remove(); + } + return document.querySelectorAll('.onboarding-a-overlay.visible').length === 0; + `) as boolean; + }, 5_000, 'Onboarding overlay did not disappear'); +} + +async function getVisibleTreeLabels(): Promise { + const driver = VSBrowser.instance.driver; + return await driver.executeScript(` + return Array.from(document.querySelectorAll('.monaco-list-row')) + .map(row => row.textContent ? row.textContent.trim().replace(/\\s+/g, ' ') : '') + .filter(Boolean); + `) as string[]; +} + +async function waitForTreeLabel(label: string, timeoutMs = 15_000): Promise { + await VSBrowser.instance.driver.wait(async () => { + const labels = await getVisibleTreeLabels(); + return labels.some(t => t.includes(label)); + }, timeoutMs, `Tree item "${label}" not found`); +} + +async function waitForTreeLabelAbsent(label: string, timeoutMs = 15_000): Promise { + await VSBrowser.instance.driver.wait(async () => { + const labels = await getVisibleTreeLabels(); + return !labels.some(t => t.includes(label)); + }, timeoutMs, `Tree item "${label}" is still visible`); +} + +async function openVirtualTabsView(): Promise { + await dismissOnboardingOverlay(); + const activityBar = new ActivityBar(); + const viewControl = (await activityBar.getViewControl('Virtual Tabs')) as ViewControl; + expect(viewControl, 'Virtual Tabs icon not found in Activity Bar').to.not.be.undefined; + let sidebar: SideBarView; + try { + sidebar = await viewControl.openView() as SideBarView; + } catch { + await dismissOnboardingOverlay(); + await viewControl.getDriver().executeScript('arguments[0].click()', viewControl); + sidebar = await new SideBarView().wait(); + } + + await VSBrowser.instance.driver.wait(async () => { + const labels = await getVisibleTreeLabels(); + return labels.length > 0; + }, 30_000, 'Virtual Tabs extension did not activate within 30 s'); + + return sidebar; +} + +async function getVirtualTabsSection(sidebar: SideBarView): Promise { + const content = sidebar.getContent(); + return await content.getSection( + section => section.getTitle().then(title => title.toLowerCase().includes('virtual tabs')), + CustomTreeSection + ); +} + +async function findTreeItem(section: CustomTreeSection, label: string, timeoutMs: number = 10_000): Promise { + const driver = VSBrowser.instance.driver; + await driver.wait(async () => (await section.findItem(label)) !== undefined, timeoutMs, `Tree item "${label}" not found`); + return await section.findItem(label) as TreeItem; +} + +async function clickRefresh(sidebar: SideBarView): Promise { + const driver = VSBrowser.instance.driver; + await driver.wait(async () => { + try { + const actions = await sidebar.getTitlePart().getActions(); + for (const action of actions) { + const title = await action.getTitle(); + if (/refresh/i.test(title)) { + await action.click(); + return true; + } + } + return false; + } catch { + return false; + } + }, 10_000, 'Refresh button not found'); +} + +describe('Virtual Tabs - Remove selected files from group', function () { + this.timeout(90_000); + + before(async function () { + await VSBrowser.instance.waitForWorkbench(); + await dismissOnboardingOverlay(); + }); + + after(async function () { + await new EditorView().closeAllEditors(); + if (fs.existsSync(regressionFileAbsolutePath)) { + fs.unlinkSync(regressionFileAbsolutePath); + } + writeConfig(repoAConfigPath, repoAOriginal); + }); + + it('removes a file from a group when config stores relative file paths after reload', async function () { + fs.mkdirSync(path.dirname(regressionFileAbsolutePath), { recursive: true }); + fs.writeFileSync(regressionFileAbsolutePath, 'export const removeSelectedFileRegression = true;\n'); + + writeConfig(repoAConfigPath, [ + { + id: 'remove-selected-regression', + name: 'Remove Selected Regression', + files: [regressionFileRelativePath] + } + ]); + + const sidebar = await openVirtualTabsView(); + await clickRefresh(sidebar); + const section = await getVirtualTabsSection(sidebar); + const groupItem = await findTreeItem(section, 'Remove Selected Regression'); + await groupItem.expand(); + await waitForTreeLabel('remove-selected-file-regression.ts'); + + const fileItem = await findTreeItem(section, 'remove-selected-file-regression.ts'); + await fileItem.select(); + + await new Workbench().executeCommand('Remove Selected Files from Group'); + + await VSBrowser.instance.driver.wait(() => { + const groups = readConfig(repoAConfigPath); + const target = groups.find(group => group.id === 'remove-selected-regression'); + return !!target && Array.isArray(target.files) && target.files.length === 0; + }, 15_000, 'Selected file was not removed from virtualTab.json'); + + await waitForTreeLabelAbsent('remove-selected-file-regression.ts'); + }); +}); diff --git a/src/test/unit/fileEntryMatcher.test.ts b/src/test/unit/fileEntryMatcher.test.ts new file mode 100644 index 0000000..3f8cc05 --- /dev/null +++ b/src/test/unit/fileEntryMatcher.test.ts @@ -0,0 +1,28 @@ +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { matchesStoredFileEntry } from '../../core/FileEntryMatcher'; + +describe('matchesStoredFileEntry', () => { + test('matches relative stored path against file URI/fsPath using scope root', () => { + const scopeRoot = path.resolve('/workspace/project'); + const targetFsPath = path.resolve(scopeRoot, 'src/extension.ts'); + const targetUri = pathToFileURL(targetFsPath).toString(); + + expect(matchesStoredFileEntry('src/extension.ts', targetUri, targetFsPath, scopeRoot)).toBe(true); + }); + + test('matches file URI stored path directly', () => { + const targetFsPath = path.resolve('/workspace/project/src/commands.ts'); + const targetUri = pathToFileURL(targetFsPath).toString(); + + expect(matchesStoredFileEntry(targetUri, targetUri, targetFsPath, '/workspace/project')).toBe(true); + }); + + test('returns false for different file', () => { + const scopeRoot = path.resolve('/workspace/project'); + const targetFsPath = path.resolve(scopeRoot, 'src/extension.ts'); + const targetUri = pathToFileURL(targetFsPath).toString(); + + expect(matchesStoredFileEntry('src/provider.ts', targetUri, targetFsPath, scopeRoot)).toBe(false); + }); +}); From bb6d3e6267442c20e5733052001a0473f1ed7f8d Mon Sep 17 00:00:00 2001 From: winterdrive <90021888+winterdrive@users.noreply.github.com> Date: Sat, 16 May 2026 21:17:27 +0800 Subject: [PATCH 3/5] fix: enhance file removal functionality and add coverage tests --- .github/workflows/validate.yml | 4 +- .gitignore | 1 + CHANGELOG.md | 6 + DEVELOPMENT.md | 6 + jest.config.js | 16 ++ package-lock.json | 4 +- package.json | 3 +- src/commands.ts | 24 +- src/core/GroupFileRemoval.ts | 38 +++ src/core/GroupFileTargets.ts | 18 ++ src/provider.ts | 23 +- src/test/ui/builtInGroup.ui.test.ts | 27 +- .../removeSelectedFilesFromGroup.ui.test.ts | 72 ++++- src/test/ui/scopeFilter.ui.test.ts | 32 ++- src/test/unit/fileEntryMatcher.test.ts | 29 ++ src/test/unit/groupFileRemoval.test.ts | 57 ++++ src/test/unit/groupFileTargets.test.ts | 29 ++ src/test/unit/removeFilesFromGroup.test.ts | 271 ++++++++++++++++++ 18 files changed, 603 insertions(+), 57 deletions(-) create mode 100644 src/core/GroupFileRemoval.ts create mode 100644 src/core/GroupFileTargets.ts create mode 100644 src/test/unit/groupFileRemoval.test.ts create mode 100644 src/test/unit/groupFileTargets.test.ts create mode 100644 src/test/unit/removeFilesFromGroup.test.ts diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index f9267f7..f833526 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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 diff --git a/.gitignore b/.gitignore index 444a7ed..07476f9 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ test-resources/ # Testing /coverage test-ui-output.txt +docs/assets/fix_rounded_corners.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 264bda4..901f301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ 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 across URI, absolute path, and workspace-relative path storage forms. +- Added `npm run test:coverage` and updated PR validation to run Jest coverage for the file-entry matching helper before packaging. ## [0.5.5] - 2026-05-13 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8267830..cdf45b9 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -137,6 +137,7 @@ 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. | @@ -144,10 +145,15 @@ VirtualTabs uses three automated test layers: ```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`: diff --git a/jest.config.js b/jest.config.js index 84072cc..35c9549 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,5 +7,21 @@ module.exports = { modulePathIgnorePatterns: ['/.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 + } } }; diff --git a/package-lock.json b/package-lock.json index b1cedba..849c204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "virtual-tabs", - "version": "0.5.7", + "version": "0.5.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "virtual-tabs", - "version": "0.5.7", + "version": "0.5.8", "license": "MIT", "dependencies": { "fast-glob": "^3.3.3" diff --git a/package.json b/package.json index ae5ab81..6b3507e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "virtual-tabs", "displayName": "VirtualTabs - Virtual File Directories & AI context management", "description": "%extension.description%", - "version": "0.5.7", + "version": "0.5.8", "publisher": "winterdrive", "icon": "docs/assets/virtualtabs_icon_128.png", "categories": [ @@ -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", diff --git a/src/commands.ts b/src/commands.ts index 02c1fe2..9c73122 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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)[] = []; @@ -633,15 +634,7 @@ 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 = new Map(); - for (const fileItem of filesToRemove) { - const current = filesByGroup.get(fileItem.groupIdx); - if (current) { - current.push(fileItem); - } else { - filesByGroup.set(fileItem.groupIdx, [fileItem]); - } - } + const filesByGroup = groupItemsByGroupIdx(filesToRemove); for (const [groupIdx, groupFiles] of filesByGroup) { provider.removeFilesFromGroup(groupIdx, groupFiles); @@ -855,16 +848,9 @@ export function registerCommands( const executeRemove = () => { - const filesByGroup = new Map(); - for (const fileItem of filesToRemove) { - if (!(fileItem instanceof TempFileItem)) continue; - const current = filesByGroup.get(fileItem.groupIdx); - if (current) { - current.push(fileItem); - } else { - filesByGroup.set(fileItem.groupIdx, [fileItem]); - } - } + const filesByGroup = groupItemsByGroupIdx( + filesToRemove.filter((fileItem): fileItem is TempFileItem => fileItem instanceof TempFileItem) + ); for (const [groupIdx, groupFiles] of filesByGroup) { provider.removeFilesFromGroup(groupIdx, groupFiles); diff --git a/src/core/GroupFileRemoval.ts b/src/core/GroupFileRemoval.ts new file mode 100644 index 0000000..af5de5f --- /dev/null +++ b/src/core/GroupFileRemoval.ts @@ -0,0 +1,38 @@ +import { TempGroup } from '../types'; +import { matchesStoredFileEntry } from './FileEntryMatcher'; + +export interface FileRemovalTarget { + uri: string; + fsPath: string; +} + +export function removeStoredFileEntriesFromGroup( + group: Pick, + 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; +} diff --git a/src/core/GroupFileTargets.ts b/src/core/GroupFileTargets.ts new file mode 100644 index 0000000..7f5b6a0 --- /dev/null +++ b/src/core/GroupFileTargets.ts @@ -0,0 +1,18 @@ +export interface GroupIndexedItem { + groupIdx: number; +} + +export function groupItemsByGroupIdx(items: T[]): Map { + const itemsByGroup = new Map(); + + for (const item of items) { + const current = itemsByGroup.get(item.groupIdx); + if (current) { + current.push(item); + } else { + itemsByGroup.set(item.groupIdx, [item]); + } + } + + return itemsByGroup; +} diff --git a/src/provider.ts b/src/provider.ts index b1d81e3..09f161a 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -9,7 +9,7 @@ import { BookmarkManager } from './core/BookmarkManager'; import { GroupManager, OptimisticLockError } from './core/GroupManager'; import { PathUtils } from './core/PathUtils'; import { ConfigScopeDiscovery } from './core/ConfigScopeDiscovery'; -import { matchesStoredFileEntry } from './core/FileEntryMatcher'; +import { removeStoredFileEntriesFromGroup } from './core/GroupFileRemoval'; export const BUILTIN_SCOPE_ID = '__builtin__'; @@ -1336,26 +1336,7 @@ export class TempFoldersProvider implements vscode.TreeDataProvider - !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; - } - } - - if (group.files.length !== originalLength) { + if (removeStoredFileEntriesFromGroup(group, targets, scopeRoot)) { this.refresh(); } } diff --git a/src/test/ui/builtInGroup.ui.test.ts b/src/test/ui/builtInGroup.ui.test.ts index a2e1911..837d200 100644 --- a/src/test/ui/builtInGroup.ui.test.ts +++ b/src/test/ui/builtInGroup.ui.test.ts @@ -64,8 +64,14 @@ async function getVisibleTreeLabels(): Promise { async function openVirtualTabsView(): Promise { 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 { @@ -125,6 +131,12 @@ async function clickToolbarButton(sidebar: SideBarView, titlePattern: RegExp): P }, 10_000, `Toolbar button matching "${titlePattern}" not found`); } +async function reloadVirtualTabsView(): Promise { + const sidebar = await openVirtualTabsView(); + await clickToolbarButton(sidebar, /refresh/i); + return sidebar; +} + // ───────────────────────────────────────────────────────────────────────────── @@ -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'); @@ -165,7 +177,7 @@ describe('Virtual Tabs – Built-in 群組初始化與可見性', function () { writeConfig(repoAConfigPath, []); writeConfig(repoBConfigPath, []); - await openVirtualTabsView(); + await reloadVirtualTabsView(); // 沒有任何自訂群組 await waitForTreeLabelAbsent('Feature Group'); @@ -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); @@ -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); @@ -221,7 +230,7 @@ describe('Virtual Tabs – Built-in 群組初始化與可見性', function () { writeConfig(repoAConfigPath, repoAOriginal); writeConfig(repoBConfigPath, repoBOriginal); - await openVirtualTabsView(); + await reloadVirtualTabsView(); const labels = await getVisibleTreeLabels(); diff --git a/src/test/ui/removeSelectedFilesFromGroup.ui.test.ts b/src/test/ui/removeSelectedFilesFromGroup.ui.test.ts index 59cad9c..24efee3 100644 --- a/src/test/ui/removeSelectedFilesFromGroup.ui.test.ts +++ b/src/test/ui/removeSelectedFilesFromGroup.ui.test.ts @@ -18,6 +18,10 @@ const repoAConfigPath = path.join(repoAPath, '.vscode', 'virtualTab.json'); const repoAOriginal = [{ id: 'repo-a-existing', name: 'Repo A Existing', files: [] }]; const regressionFileRelativePath = 'src/remove-selected-file-regression.ts'; const regressionFileAbsolutePath = path.join(repoAPath, regressionFileRelativePath); +const firstGroupFileRelativePath = 'src/remove-selected-first-group.ts'; +const secondGroupFileRelativePath = 'src/remove-selected-second-group.ts'; +const firstGroupFileAbsolutePath = path.join(repoAPath, firstGroupFileRelativePath); +const secondGroupFileAbsolutePath = path.join(repoAPath, secondGroupFileRelativePath); function writeConfig(configPath: string, groups: object[]): void { fs.writeFileSync(configPath, `${JSON.stringify(groups, null, 2)}\n`); @@ -134,8 +138,14 @@ describe('Virtual Tabs - Remove selected files from group', function () { after(async function () { await new EditorView().closeAllEditors(); - if (fs.existsSync(regressionFileAbsolutePath)) { - fs.unlinkSync(regressionFileAbsolutePath); + for (const filePath of [ + regressionFileAbsolutePath, + firstGroupFileAbsolutePath, + secondGroupFileAbsolutePath + ]) { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } } writeConfig(repoAConfigPath, repoAOriginal); }); @@ -172,4 +182,62 @@ describe('Virtual Tabs - Remove selected files from group', function () { await waitForTreeLabelAbsent('remove-selected-file-regression.ts'); }); + + it('removes reloaded relative-path files from separate groups without cross-group leakage', async function () { + fs.mkdirSync(path.dirname(firstGroupFileAbsolutePath), { recursive: true }); + fs.writeFileSync(firstGroupFileAbsolutePath, 'export const removeSelectedFirstGroup = true;\n'); + fs.writeFileSync(secondGroupFileAbsolutePath, 'export const removeSelectedSecondGroup = true;\n'); + + writeConfig(repoAConfigPath, [ + { + id: 'remove-selected-first-group', + name: 'Remove Selected First Group', + files: [firstGroupFileRelativePath] + }, + { + id: 'remove-selected-second-group', + name: 'Remove Selected Second Group', + files: [secondGroupFileRelativePath] + } + ]); + + const sidebar = await openVirtualTabsView(); + await clickRefresh(sidebar); + const section = await getVirtualTabsSection(sidebar); + + const firstGroupItem = await findTreeItem(section, 'Remove Selected First Group'); + await firstGroupItem.expand(); + const secondGroupItem = await findTreeItem(section, 'Remove Selected Second Group'); + await secondGroupItem.expand(); + await waitForTreeLabel('remove-selected-first-group.ts'); + await waitForTreeLabel('remove-selected-second-group.ts'); + + const firstFileItem = await findTreeItem(section, 'remove-selected-first-group.ts'); + await firstFileItem.select(); + await new Workbench().executeCommand('Remove Selected Files from Group'); + + await VSBrowser.instance.driver.wait(() => { + const groups = readConfig(repoAConfigPath); + const first = groups.find(group => group.id === 'remove-selected-first-group'); + const second = groups.find(group => group.id === 'remove-selected-second-group'); + return !!first && !!second && + Array.isArray(first.files) && first.files.length === 0 && + Array.isArray(second.files) && second.files.includes(secondGroupFileRelativePath); + }, 15_000, 'First group file was not removed or second group changed unexpectedly'); + await waitForTreeLabelAbsent('remove-selected-first-group.ts'); + + const secondFileItem = await findTreeItem(section, 'remove-selected-second-group.ts'); + await secondFileItem.select(); + await new Workbench().executeCommand('Remove Selected Files from Group'); + + await VSBrowser.instance.driver.wait(() => { + const groups = readConfig(repoAConfigPath); + const first = groups.find(group => group.id === 'remove-selected-first-group'); + const second = groups.find(group => group.id === 'remove-selected-second-group'); + return !!first && !!second && + Array.isArray(first.files) && first.files.length === 0 && + Array.isArray(second.files) && second.files.length === 0; + }, 15_000, 'Second group file was not removed from virtualTab.json'); + await waitForTreeLabelAbsent('remove-selected-second-group.ts'); + }); }); diff --git a/src/test/ui/scopeFilter.ui.test.ts b/src/test/ui/scopeFilter.ui.test.ts index 07a6962..2ca13d1 100644 --- a/src/test/ui/scopeFilter.ui.test.ts +++ b/src/test/ui/scopeFilter.ui.test.ts @@ -112,6 +112,34 @@ async function openVirtualTabsView(): Promise { return sidebar; } +async function clickToolbarButton(sidebar: SideBarView, titlePattern: RegExp): Promise { + const driver = VSBrowser.instance.driver; + await driver.wait(async () => { + try { + const actions = await sidebar.getTitlePart().getActions(); + for (const action of actions) { + const title = await action.getTitle(); + if (titlePattern.test(title)) { + await action.click(); + return true; + } + } + return false; + } catch (error) { + if ((error as Error).name === 'StaleElementReferenceError') { + return false; + } + throw error; + } + }, 10_000, `Toolbar button matching "${titlePattern}" not found`); +} + +async function reloadVirtualTabsView(): Promise { + const sidebar = await openVirtualTabsView(); + await clickToolbarButton(sidebar, /refresh/i); + return sidebar; +} + // ─── QuickPick Helper ───────────────────────────────────────────────────────── /** @@ -195,7 +223,9 @@ describe('Virtual Tabs – Scope 篩選器 UI', function () { writeConfig(repoBConfigPath, repoBOriginal); await VSBrowser.instance.waitForWorkbench(); await dismissOnboardingOverlay(); - await openVirtualTabsView(); + await reloadVirtualTabsView(); + await waitForSidebarLabel('Repo A Existing'); + await waitForSidebarLabel('Repo B Existing'); // 確保篩選器從「顯示全部」狀態開始 await resetScopeFilter(); diff --git a/src/test/unit/fileEntryMatcher.test.ts b/src/test/unit/fileEntryMatcher.test.ts index 3f8cc05..7b20679 100644 --- a/src/test/unit/fileEntryMatcher.test.ts +++ b/src/test/unit/fileEntryMatcher.test.ts @@ -3,6 +3,13 @@ import { pathToFileURL } from 'url'; import { matchesStoredFileEntry } from '../../core/FileEntryMatcher'; describe('matchesStoredFileEntry', () => { + test('returns false for an empty stored path', () => { + const targetFsPath = path.resolve('/workspace/project/src/extension.ts'); + const targetUri = pathToFileURL(targetFsPath).toString(); + + expect(matchesStoredFileEntry('', targetUri, targetFsPath, '/workspace/project')).toBe(false); + }); + test('matches relative stored path against file URI/fsPath using scope root', () => { const scopeRoot = path.resolve('/workspace/project'); const targetFsPath = path.resolve(scopeRoot, 'src/extension.ts'); @@ -11,6 +18,20 @@ describe('matchesStoredFileEntry', () => { expect(matchesStoredFileEntry('src/extension.ts', targetUri, targetFsPath, scopeRoot)).toBe(true); }); + test('does not match a relative stored path without a scope root', () => { + const targetFsPath = path.resolve('/workspace/project/src/extension.ts'); + const targetUri = pathToFileURL(targetFsPath).toString(); + + expect(matchesStoredFileEntry('src/extension.ts', targetUri, targetFsPath)).toBe(false); + }); + + test('matches absolute stored paths by normalized filesystem path', () => { + const targetFsPath = path.resolve('/workspace/project/src/provider.ts'); + const targetUri = pathToFileURL(targetFsPath).toString(); + + expect(matchesStoredFileEntry(targetFsPath, targetUri, targetFsPath, '/workspace/project')).toBe(true); + }); + test('matches file URI stored path directly', () => { const targetFsPath = path.resolve('/workspace/project/src/commands.ts'); const targetUri = pathToFileURL(targetFsPath).toString(); @@ -18,6 +39,14 @@ describe('matchesStoredFileEntry', () => { expect(matchesStoredFileEntry(targetUri, targetUri, targetFsPath, '/workspace/project')).toBe(true); }); + test('matches file URI stored path by filesystem path when URI strings differ but resolve to the same file', () => { + const targetFsPath = path.resolve('/workspace/project/src/file with spaces.ts'); + const targetUri = pathToFileURL(targetFsPath).toString(); + const localhostUri = targetUri.replace('file:///', 'file://localhost/'); + + expect(matchesStoredFileEntry(localhostUri, targetUri, targetFsPath, '/workspace/project')).toBe(true); + }); + test('returns false for different file', () => { const scopeRoot = path.resolve('/workspace/project'); const targetFsPath = path.resolve(scopeRoot, 'src/extension.ts'); diff --git a/src/test/unit/groupFileRemoval.test.ts b/src/test/unit/groupFileRemoval.test.ts new file mode 100644 index 0000000..4129e7d --- /dev/null +++ b/src/test/unit/groupFileRemoval.test.ts @@ -0,0 +1,57 @@ +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { removeStoredFileEntriesFromGroup } from '../../core/GroupFileRemoval'; +import type { TempGroup } from '../../types'; + +function createTarget(scopeRoot: string, relativePath: string) { + const fsPath = path.join(scopeRoot, relativePath); + return { + uri: pathToFileURL(fsPath).toString(), + fsPath + }; +} + +describe('removeStoredFileEntriesFromGroup', () => { + test('returns false when the group has no files', () => { + const group: Pick = {}; + const target = createTarget(path.resolve('/workspace/project'), 'src/extension.ts'); + + expect(removeStoredFileEntriesFromGroup(group, [target], '/workspace/project')).toBe(false); + expect(group.files).toBeUndefined(); + }); + + test('returns false when there are no selected targets', () => { + const group: Pick = { + files: ['src/extension.ts'] + }; + + expect(removeStoredFileEntriesFromGroup(group, [], '/workspace/project')).toBe(false); + expect(group.files).toEqual(['src/extension.ts']); + }); + + test('removes every selected relative file and keeps unmatched bookmarks', () => { + const scopeRoot = path.resolve('/workspace/project'); + const group: Pick = { + files: ['src/extension.ts', 'src/commands.ts', 'README.md'], + bookmarks: { + 'src/extension.ts': [{ id: 'b1', line: 1, label: 'extension', created: 1 }], + 'README.md': [{ id: 'b2', line: 2, label: 'readme', created: 2 }] + } + }; + + const removed = removeStoredFileEntriesFromGroup( + group, + [ + createTarget(scopeRoot, 'src/extension.ts'), + createTarget(scopeRoot, 'src/commands.ts') + ], + scopeRoot + ); + + expect(removed).toBe(true); + expect(group.files).toEqual(['README.md']); + expect(group.bookmarks).toEqual({ + 'README.md': [{ id: 'b2', line: 2, label: 'readme', created: 2 }] + }); + }); +}); diff --git a/src/test/unit/groupFileTargets.test.ts b/src/test/unit/groupFileTargets.test.ts new file mode 100644 index 0000000..0a97336 --- /dev/null +++ b/src/test/unit/groupFileTargets.test.ts @@ -0,0 +1,29 @@ +import { groupItemsByGroupIdx } from '../../core/GroupFileTargets'; + +describe('groupItemsByGroupIdx', () => { + test('returns an empty map for no selected items', () => { + expect(groupItemsByGroupIdx([]).size).toBe(0); + }); + + test('keeps one group when all selected items belong to the same group', () => { + const first = { groupIdx: 1, id: 'a' }; + const second = { groupIdx: 1, id: 'b' }; + + const result = groupItemsByGroupIdx([first, second]); + + expect(Array.from(result.keys())).toEqual([1]); + expect(result.get(1)).toEqual([first, second]); + }); + + test('preserves insertion order while splitting selected items by group index', () => { + const first = { groupIdx: 2, id: 'a' }; + const second = { groupIdx: 1, id: 'b' }; + const third = { groupIdx: 2, id: 'c' }; + + const result = groupItemsByGroupIdx([first, second, third]); + + expect(Array.from(result.keys())).toEqual([2, 1]); + expect(result.get(2)).toEqual([first, third]); + expect(result.get(1)).toEqual([second]); + }); +}); diff --git a/src/test/unit/removeFilesFromGroup.test.ts b/src/test/unit/removeFilesFromGroup.test.ts new file mode 100644 index 0000000..9e30b97 --- /dev/null +++ b/src/test/unit/removeFilesFromGroup.test.ts @@ -0,0 +1,271 @@ +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import type { TempGroup, ConfigScope } from '../../types'; + +jest.mock('vscode', () => { + class Uri { + private constructor(public readonly fsPath: string) { } + + static file(fsPath: string): Uri { + return new Uri(path.resolve(fsPath)); + } + + static parse(value: string): Uri { + if (value.startsWith('file://')) { + return new Uri(new URL(value).pathname); + } + return new Uri(value); + } + + static joinPath(base: Uri, ...segments: string[]): Uri { + return new Uri(path.join(base.fsPath, ...segments)); + } + + toString(): string { + return pathToFileURL(this.fsPath).toString(); + } + } + + class TreeItem { + id?: string; + resourceUri?: Uri; + command?: unknown; + iconPath?: unknown; + tooltip?: unknown; + contextValue?: string; + + constructor(public readonly label: unknown, public readonly collapsibleState?: number) { } + } + + return { + Uri, + TreeItem, + TreeItemCollapsibleState: { + None: 0, + Collapsed: 1, + Expanded: 2 + }, + EventEmitter: class { + event = jest.fn(); + fire = jest.fn(); + }, + ThemeIcon: Object.assign( + class { + constructor(public readonly id: string, public readonly color?: unknown) { } + }, + { File: { id: 'file' } } + ), + ThemeColor: class { + constructor(public readonly id: string) { } + }, + TabInputText: class { }, + TabInputNotebook: class { }, + TabInputCustom: class { }, + TabInputTextDiff: class { }, + commands: { + executeCommand: jest.fn() + }, + window: { + tabGroups: { + all: [] + }, + showErrorMessage: jest.fn(), + showInformationMessage: jest.fn() + }, + workspace: { + workspaceFolders: [] + } + }; +}, { virtual: true }); + +import * as vscode from 'vscode'; +import { TempFoldersProvider } from '../../provider'; + +function createProviderHarness(groups: TempGroup[], configScopes: ConfigScope[]): TempFoldersProvider { + const provider = Object.create(TempFoldersProvider.prototype) as TempFoldersProvider; + provider.groups = groups; + provider.configScopes = configScopes; + jest.spyOn(provider, 'refresh').mockImplementation(jest.fn()); + return provider; +} + +function createFileItem(fsPath: string) { + const uri = vscode.Uri.file(fsPath); + return { uri } as never; +} + +describe('TempFoldersProvider.removeFilesFromGroup', () => { + test('removes a reloaded workspace-relative file from its group', () => { + const workspaceRoot = path.resolve('/workspace/project'); + const targetPath = path.join(workspaceRoot, 'src', 'extension.ts'); + const scopeId = 'scope:project'; + const group: TempGroup = { + id: 'group-1', + name: 'New Group 1', + sourceScopeId: scopeId, + files: ['src/extension.ts', 'src/commands.ts'] + }; + + const provider = createProviderHarness([group], [{ + id: scopeId, + type: 'folder', + label: 'project', + uri: vscode.Uri.file(workspaceRoot), + groups: [] + }]); + + provider.removeFilesFromGroup(0, [createFileItem(targetPath)]); + + expect(group.files).toEqual(['src/commands.ts']); + expect(provider.refresh).toHaveBeenCalledTimes(1); + }); + + test('removes multiple selected relative-path files from the same reloaded group', () => { + const workspaceRoot = path.resolve('/workspace/project'); + const scopeId = 'scope:project'; + const group: TempGroup = { + id: 'group-1', + name: 'New Group 1', + sourceScopeId: scopeId, + files: ['src/extension.ts', 'src/commands.ts', 'src/provider.ts'] + }; + + const provider = createProviderHarness([group], [{ + id: scopeId, + type: 'folder', + label: 'project', + uri: vscode.Uri.file(workspaceRoot), + groups: [] + }]); + + provider.removeFilesFromGroup(0, [ + createFileItem(path.join(workspaceRoot, 'src', 'extension.ts')), + createFileItem(path.join(workspaceRoot, 'src', 'commands.ts')) + ]); + + expect(group.files).toEqual(['src/provider.ts']); + expect(provider.refresh).toHaveBeenCalledTimes(1); + }); + + test('uses the matching source scope root when groups belong to different folders', () => { + const repoARoot = path.resolve('/workspace/Repo-A'); + const repoBRoot = path.resolve('/workspace/Repo-B'); + const repoAGroup: TempGroup = { + id: 'group-a', + name: 'Repo A Group', + sourceScopeId: 'scope:a', + files: ['src/shared.ts'] + }; + const repoBGroup: TempGroup = { + id: 'group-b', + name: 'Repo B Group', + sourceScopeId: 'scope:b', + files: ['src/shared.ts'] + }; + + const provider = createProviderHarness([repoAGroup, repoBGroup], [ + { + id: 'scope:a', + type: 'folder', + label: 'Repo-A', + uri: vscode.Uri.file(repoARoot), + groups: [] + }, + { + id: 'scope:b', + type: 'folder', + label: 'Repo-B', + uri: vscode.Uri.file(repoBRoot), + groups: [] + } + ]); + + provider.removeFilesFromGroup(0, [ + createFileItem(path.join(repoARoot, 'src', 'shared.ts')) + ]); + + expect(repoAGroup.files).toEqual([]); + expect(repoBGroup.files).toEqual(['src/shared.ts']); + expect(provider.refresh).toHaveBeenCalledTimes(1); + }); + + test('falls back to the workspace root for legacy groups without source scope', () => { + const workspaceRoot = path.resolve('/workspace/project'); + const group: TempGroup = { + id: 'legacy-group', + name: 'Legacy Group', + files: ['src/extension.ts'] + }; + + const provider = createProviderHarness([group], []); + (vscode.workspace as unknown as { workspaceFolders: Array<{ uri: { fsPath: string } }> }).workspaceFolders = [ + { uri: vscode.Uri.file(workspaceRoot) } + ]; + + provider.removeFilesFromGroup(0, [ + createFileItem(path.join(workspaceRoot, 'src', 'extension.ts')) + ]); + + expect(group.files).toEqual([]); + expect(provider.refresh).toHaveBeenCalledTimes(1); + }); + + test('removes bookmarks stored with the same workspace-relative file path', () => { + const workspaceRoot = path.resolve('/workspace/project'); + const targetPath = path.join(workspaceRoot, 'src', 'extension.ts'); + const scopeId = 'scope:project'; + const group: TempGroup = { + id: 'group-1', + name: 'New Group 1', + sourceScopeId: scopeId, + files: ['src/extension.ts'], + bookmarks: { + 'src/extension.ts': [{ + id: 'bookmark-1', + line: 12, + label: 'important', + created: 1 + }] + } + }; + + const provider = createProviderHarness([group], [{ + id: scopeId, + type: 'folder', + label: 'project', + uri: vscode.Uri.file(workspaceRoot), + groups: [] + }]); + + provider.removeFilesFromGroup(0, [createFileItem(targetPath)]); + + expect(group.files).toEqual([]); + expect(group.bookmarks).toBeUndefined(); + expect(provider.refresh).toHaveBeenCalledTimes(1); + }); + + test('does not refresh when selected files do not match stored entries', () => { + const workspaceRoot = path.resolve('/workspace/project'); + const targetPath = path.join(workspaceRoot, 'src', 'provider.ts'); + const scopeId = 'scope:project'; + const group: TempGroup = { + id: 'group-1', + name: 'New Group 1', + sourceScopeId: scopeId, + files: ['src/extension.ts'] + }; + + const provider = createProviderHarness([group], [{ + id: scopeId, + type: 'folder', + label: 'project', + uri: vscode.Uri.file(workspaceRoot), + groups: [] + }]); + + provider.removeFilesFromGroup(0, [createFileItem(targetPath)]); + + expect(group.files).toEqual(['src/extension.ts']); + expect(provider.refresh).not.toHaveBeenCalled(); + }); +}); From 6d9385c314eb95637465ae7a34a665ba2c2c2adf Mon Sep 17 00:00:00 2001 From: winterdrive <90021888+winterdrive@users.noreply.github.com> Date: Sat, 16 May 2026 21:19:48 +0800 Subject: [PATCH 4/5] fix: update version to 0.6.0 in package.json and package-lock.json --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 849c204..8de1b94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "virtual-tabs", - "version": "0.5.8", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "virtual-tabs", - "version": "0.5.8", + "version": "0.6.0", "license": "MIT", "dependencies": { "fast-glob": "^3.3.3" diff --git a/package.json b/package.json index 6b3507e..30f70d1 100644 --- a/package.json +++ b/package.json @@ -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": [ From fd44034e507a1928423edf8337a49e495772507f Mon Sep 17 00:00:00 2001 From: winterdrive <90021888+winterdrive@users.noreply.github.com> Date: Sat, 16 May 2026 21:35:48 +0800 Subject: [PATCH 5/5] fix: update changelog for version 0.6.0 and enhance coverage details for group file removal --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 901f301..92edc3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -15,8 +15,9 @@ All notable changes to the "VirtualTabs" extension will be documented in this fi ### Tests and CI -- Added focused unit coverage for file-entry matching across URI, absolute path, and workspace-relative path storage forms. -- Added `npm run test:coverage` and updated PR validation to run Jest coverage for the file-entry matching helper before packaging. +- 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