Skip to content

Commit 5a95d80

Browse files
Copilotwinterdrive
andauthored
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>
1 parent a6c22bd commit 5a95d80

5 files changed

Lines changed: 296 additions & 32 deletions

File tree

src/commands.ts

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -633,10 +633,19 @@ export function registerCommands(
633633
context.subscriptions.push(vscode.commands.registerCommand('virtualTabs.removeSelectedFilesFromGroup', (item?: TempFileItem) => {
634634
const filesToRemove = resolveTargetItems(item, provider);
635635
if (filesToRemove.length === 0) return;
636+
const filesByGroup = new Map<number, TempFileItem[]>();
637+
for (const fileItem of filesToRemove) {
638+
const current = filesByGroup.get(fileItem.groupIdx);
639+
if (current) {
640+
current.push(fileItem);
641+
} else {
642+
filesByGroup.set(fileItem.groupIdx, [fileItem]);
643+
}
644+
}
636645

637-
// Use the group index from the first file item
638-
const fileItem = filesToRemove[0];
639-
provider.removeFilesFromGroup(fileItem.groupIdx, filesToRemove);
646+
for (const [groupIdx, groupFiles] of filesByGroup) {
647+
provider.removeFilesFromGroup(groupIdx, groupFiles);
648+
}
640649
}));
641650

642651
// Group context menu "Add selected files to group"
@@ -846,34 +855,19 @@ export function registerCommands(
846855

847856

848857
const executeRemove = () => {
849-
let hasChanges = false;
850-
858+
const filesByGroup = new Map<number, TempFileItem[]>();
851859
for (const fileItem of filesToRemove) {
852860
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-
}
861+
const current = filesByGroup.get(fileItem.groupIdx);
862+
if (current) {
863+
current.push(fileItem);
864+
} else {
865+
filesByGroup.set(fileItem.groupIdx, [fileItem]);
872866
}
873867
}
874868

875-
if (hasChanges) {
876-
provider.refresh();
869+
for (const [groupIdx, groupFiles] of filesByGroup) {
870+
provider.removeFilesFromGroup(groupIdx, groupFiles);
877871
}
878872
};
879873

@@ -1822,4 +1816,3 @@ export function registerCommands(
18221816
})
18231817
);
18241818
}
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/provider.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { BookmarkManager } from './core/BookmarkManager';
99
import { GroupManager, OptimisticLockError } from './core/GroupManager';
1010
import { PathUtils } from './core/PathUtils';
1111
import { ConfigScopeDiscovery } from './core/ConfigScopeDiscovery';
12+
import { matchesStoredFileEntry } from './core/FileEntryMatcher';
1213

1314
export const BUILTIN_SCOPE_ID = '__builtin__';
1415

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

1330-
// Ensure all selected files belong to the specified group
1331-
const uriStrings = fileItems.map(item => item.uri.toString());
1334+
const targets = fileItems.map(item => ({
1335+
uri: item.uri.toString(),
1336+
fsPath: item.uri.fsPath
1337+
}));
13321338

1333-
// Remove files from the specified group
1334-
group.files = group.files.filter(uriStr => !uriStrings.includes(uriStr));
1339+
const originalLength = group.files.length;
1340+
group.files = group.files.filter(storedEntry =>
1341+
!targets.some(target => matchesStoredFileEntry(storedEntry, target.uri, target.fsPath, scopeRoot))
1342+
);
13351343

1344+
if (group.bookmarks) {
1345+
for (const bookmarkKey of Object.keys(group.bookmarks)) {
1346+
const shouldDelete = targets.some(target =>
1347+
matchesStoredFileEntry(bookmarkKey, target.uri, target.fsPath, scopeRoot)
1348+
);
1349+
if (shouldDelete) {
1350+
delete group.bookmarks[bookmarkKey];
1351+
}
1352+
}
1353+
if (Object.keys(group.bookmarks).length === 0) {
1354+
delete group.bookmarks;
1355+
}
1356+
}
13361357

1337-
this.refresh();
1358+
if (group.files.length !== originalLength) {
1359+
this.refresh();
1360+
}
13381361
}
13391362

13401363
// Add multiple selected files to a specified group
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import {
4+
ActivityBar,
5+
CustomTreeSection,
6+
EditorView,
7+
SideBarView,
8+
TreeItem,
9+
ViewControl,
10+
VSBrowser,
11+
Workbench
12+
} from 'vscode-extension-tester';
13+
import { expect } from 'chai';
14+
15+
const fixtureRoot = path.resolve(__dirname, '../../../test-resources/multi-root');
16+
const repoAPath = path.join(fixtureRoot, 'Repo-A');
17+
const repoAConfigPath = path.join(repoAPath, '.vscode', 'virtualTab.json');
18+
const repoAOriginal = [{ id: 'repo-a-existing', name: 'Repo A Existing', files: [] }];
19+
const regressionFileRelativePath = 'src/remove-selected-file-regression.ts';
20+
const regressionFileAbsolutePath = path.join(repoAPath, regressionFileRelativePath);
21+
22+
function writeConfig(configPath: string, groups: object[]): void {
23+
fs.writeFileSync(configPath, `${JSON.stringify(groups, null, 2)}\n`);
24+
}
25+
26+
function readConfig(configPath: string): Array<{ id?: string; name?: string; files?: string[] }> {
27+
return JSON.parse(fs.readFileSync(configPath, 'utf8')) as Array<{ id?: string; name?: string; files?: string[] }>;
28+
}
29+
30+
async function dismissOnboardingOverlay(): Promise<void> {
31+
const driver = VSBrowser.instance.driver;
32+
await driver.wait(async () => {
33+
return await driver.executeScript(`
34+
const styleId = 'virtual-tabs-e2e-hide-onboarding-remove';
35+
if (!document.getElementById(styleId)) {
36+
const style = document.createElement('style');
37+
style.id = styleId;
38+
style.textContent = '.onboarding-a-overlay { display: none !important; }';
39+
document.head.appendChild(style);
40+
}
41+
for (const el of document.querySelectorAll('.onboarding-a-overlay, [aria-label="Welcome to Visual Studio Code"][role="dialog"]')) {
42+
el.remove();
43+
}
44+
return document.querySelectorAll('.onboarding-a-overlay.visible').length === 0;
45+
`) as boolean;
46+
}, 5_000, 'Onboarding overlay did not disappear');
47+
}
48+
49+
async function getVisibleTreeLabels(): Promise<string[]> {
50+
const driver = VSBrowser.instance.driver;
51+
return await driver.executeScript(`
52+
return Array.from(document.querySelectorAll('.monaco-list-row'))
53+
.map(row => row.textContent ? row.textContent.trim().replace(/\\s+/g, ' ') : '')
54+
.filter(Boolean);
55+
`) as string[];
56+
}
57+
58+
async function waitForTreeLabel(label: string, timeoutMs = 15_000): Promise<void> {
59+
await VSBrowser.instance.driver.wait(async () => {
60+
const labels = await getVisibleTreeLabels();
61+
return labels.some(t => t.includes(label));
62+
}, timeoutMs, `Tree item "${label}" not found`);
63+
}
64+
65+
async function waitForTreeLabelAbsent(label: string, timeoutMs = 15_000): Promise<void> {
66+
await VSBrowser.instance.driver.wait(async () => {
67+
const labels = await getVisibleTreeLabels();
68+
return !labels.some(t => t.includes(label));
69+
}, timeoutMs, `Tree item "${label}" is still visible`);
70+
}
71+
72+
async function openVirtualTabsView(): Promise<SideBarView> {
73+
await dismissOnboardingOverlay();
74+
const activityBar = new ActivityBar();
75+
const viewControl = (await activityBar.getViewControl('Virtual Tabs')) as ViewControl;
76+
expect(viewControl, 'Virtual Tabs icon not found in Activity Bar').to.not.be.undefined;
77+
let sidebar: SideBarView;
78+
try {
79+
sidebar = await viewControl.openView() as SideBarView;
80+
} catch {
81+
await dismissOnboardingOverlay();
82+
await viewControl.getDriver().executeScript('arguments[0].click()', viewControl);
83+
sidebar = await new SideBarView().wait();
84+
}
85+
86+
await VSBrowser.instance.driver.wait(async () => {
87+
const labels = await getVisibleTreeLabels();
88+
return labels.length > 0;
89+
}, 30_000, 'Virtual Tabs extension did not activate within 30 s');
90+
91+
return sidebar;
92+
}
93+
94+
async function getVirtualTabsSection(sidebar: SideBarView): Promise<CustomTreeSection> {
95+
const content = sidebar.getContent();
96+
return await content.getSection<CustomTreeSection>(
97+
section => section.getTitle().then(title => title.toLowerCase().includes('virtual tabs')),
98+
CustomTreeSection
99+
);
100+
}
101+
102+
async function findTreeItem(section: CustomTreeSection, label: string, timeoutMs: number = 10_000): Promise<TreeItem> {
103+
const driver = VSBrowser.instance.driver;
104+
await driver.wait(async () => (await section.findItem(label)) !== undefined, timeoutMs, `Tree item "${label}" not found`);
105+
return await section.findItem(label) as TreeItem;
106+
}
107+
108+
async function clickRefresh(sidebar: SideBarView): Promise<void> {
109+
const driver = VSBrowser.instance.driver;
110+
await driver.wait(async () => {
111+
try {
112+
const actions = await sidebar.getTitlePart().getActions();
113+
for (const action of actions) {
114+
const title = await action.getTitle();
115+
if (/refresh/i.test(title)) {
116+
await action.click();
117+
return true;
118+
}
119+
}
120+
return false;
121+
} catch {
122+
return false;
123+
}
124+
}, 10_000, 'Refresh button not found');
125+
}
126+
127+
describe('Virtual Tabs - Remove selected files from group', function () {
128+
this.timeout(90_000);
129+
130+
before(async function () {
131+
await VSBrowser.instance.waitForWorkbench();
132+
await dismissOnboardingOverlay();
133+
});
134+
135+
after(async function () {
136+
await new EditorView().closeAllEditors();
137+
if (fs.existsSync(regressionFileAbsolutePath)) {
138+
fs.unlinkSync(regressionFileAbsolutePath);
139+
}
140+
writeConfig(repoAConfigPath, repoAOriginal);
141+
});
142+
143+
it('removes a file from a group when config stores relative file paths after reload', async function () {
144+
fs.mkdirSync(path.dirname(regressionFileAbsolutePath), { recursive: true });
145+
fs.writeFileSync(regressionFileAbsolutePath, 'export const removeSelectedFileRegression = true;\n');
146+
147+
writeConfig(repoAConfigPath, [
148+
{
149+
id: 'remove-selected-regression',
150+
name: 'Remove Selected Regression',
151+
files: [regressionFileRelativePath]
152+
}
153+
]);
154+
155+
const sidebar = await openVirtualTabsView();
156+
await clickRefresh(sidebar);
157+
const section = await getVirtualTabsSection(sidebar);
158+
const groupItem = await findTreeItem(section, 'Remove Selected Regression');
159+
await groupItem.expand();
160+
await waitForTreeLabel('remove-selected-file-regression.ts');
161+
162+
const fileItem = await findTreeItem(section, 'remove-selected-file-regression.ts');
163+
await fileItem.select();
164+
165+
await new Workbench().executeCommand('Remove Selected Files from Group');
166+
167+
await VSBrowser.instance.driver.wait(() => {
168+
const groups = readConfig(repoAConfigPath);
169+
const target = groups.find(group => group.id === 'remove-selected-regression');
170+
return !!target && Array.isArray(target.files) && target.files.length === 0;
171+
}, 15_000, 'Selected file was not removed from virtualTab.json');
172+
173+
await waitForTreeLabelAbsent('remove-selected-file-regression.ts');
174+
});
175+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as path from 'path';
2+
import { pathToFileURL } from 'url';
3+
import { matchesStoredFileEntry } from '../../core/FileEntryMatcher';
4+
5+
describe('matchesStoredFileEntry', () => {
6+
test('matches relative stored path against file URI/fsPath using scope root', () => {
7+
const scopeRoot = path.resolve('/workspace/project');
8+
const targetFsPath = path.resolve(scopeRoot, 'src/extension.ts');
9+
const targetUri = pathToFileURL(targetFsPath).toString();
10+
11+
expect(matchesStoredFileEntry('src/extension.ts', targetUri, targetFsPath, scopeRoot)).toBe(true);
12+
});
13+
14+
test('matches file URI stored path directly', () => {
15+
const targetFsPath = path.resolve('/workspace/project/src/commands.ts');
16+
const targetUri = pathToFileURL(targetFsPath).toString();
17+
18+
expect(matchesStoredFileEntry(targetUri, targetUri, targetFsPath, '/workspace/project')).toBe(true);
19+
});
20+
21+
test('returns false for different file', () => {
22+
const scopeRoot = path.resolve('/workspace/project');
23+
const targetFsPath = path.resolve(scopeRoot, 'src/extension.ts');
24+
const targetUri = pathToFileURL(targetFsPath).toString();
25+
26+
expect(matchesStoredFileEntry('src/provider.ts', targetUri, targetFsPath, scopeRoot)).toBe(false);
27+
});
28+
});

0 commit comments

Comments
 (0)