Skip to content

Commit e6c3000

Browse files
priyanshu92claude
andauthored
[MDiff] Implement read-only file support in metadata diff (#1404)
* [MDiff] Implement read-only file support in metadata diff ✨ Added ReadOnlyContentProvider to handle read-only files. 🔗 Integrated read-only URIs in metadata diff handlers. 🧪 Added tests for ReadOnlyContentProvider functionality. 📝 Updated existing tests to verify read-only behavior. -Priyanshu * [MDiff] Remove unmockable fs method tests from ReadOnlyContentProvider Remove tests that attempted to stub fs.existsSync and fs.statSync, which are non-configurable properties and cannot be mocked with sinon. The remaining tests cover URI creation, singleton pattern, and write operation restrictions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 6154131 commit e6c3000

File tree

7 files changed

+328
-25
lines changed

7 files changed

+328
-25
lines changed

src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { clearMetadataDiff } from "./handlers/metadata-diff/ClearMetadataDiffHan
4646
import { viewAsTree, viewAsList } from "./handlers/metadata-diff/ToggleViewModeHandler";
4747
import { sortByName, sortByPath, sortByStatus } from "./handlers/metadata-diff/SortModeHandler";
4848
import { MetadataDiffDecorationProvider } from "./MetadataDiffDecorationProvider";
49+
import { ReadOnlyContentProvider } from "./ReadOnlyContentProvider";
4950
import { removeSiteComparison } from "./handlers/metadata-diff/RemoveSiteHandler";
5051
import { discardLocalChanges } from "./handlers/metadata-diff/DiscardLocalChangesHandler";
5152
import { discardFolderChanges } from "./handlers/metadata-diff/DiscardFolderChangesHandler";
@@ -382,7 +383,8 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider<Actio
382383
vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_EXPORT, exportMetadataDiff),
383384
vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_IMPORT, importMetadataDiff),
384385
vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_RESYNC, resyncMetadataDiff(this._pacTerminal, this._context)),
385-
MetadataDiffDecorationProvider.getInstance().register()
386+
MetadataDiffDecorationProvider.getInstance().register(),
387+
ReadOnlyContentProvider.getInstance().register()
386388
);
387389
}
388390

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*/
5+
6+
import * as vscode from "vscode";
7+
import * as fs from "fs";
8+
9+
/**
10+
* URI scheme for read-only remote files in metadata diff
11+
*/
12+
export const METADATA_DIFF_READONLY_SCHEME = "pp-metadata-diff-readonly";
13+
14+
/**
15+
* File system provider that serves local files as read-only.
16+
* Used for displaying remote/environment files in the diff editor
17+
* to prevent accidental modifications.
18+
*/
19+
export class ReadOnlyContentProvider implements vscode.FileSystemProvider {
20+
private static _instance: ReadOnlyContentProvider | undefined;
21+
private _disposable: vscode.Disposable | undefined;
22+
23+
private _onDidChangeFile = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
24+
readonly onDidChangeFile = this._onDidChangeFile.event;
25+
26+
private constructor() {
27+
// Private constructor for singleton
28+
}
29+
30+
public static getInstance(): ReadOnlyContentProvider {
31+
if (!ReadOnlyContentProvider._instance) {
32+
ReadOnlyContentProvider._instance = new ReadOnlyContentProvider();
33+
}
34+
return ReadOnlyContentProvider._instance;
35+
}
36+
37+
/**
38+
* Register the file system provider with VS Code
39+
*/
40+
public register(): vscode.Disposable {
41+
if (!this._disposable) {
42+
this._disposable = vscode.workspace.registerFileSystemProvider(
43+
METADATA_DIFF_READONLY_SCHEME,
44+
this,
45+
{ isReadonly: true }
46+
);
47+
}
48+
return this._disposable;
49+
}
50+
51+
/**
52+
* Create a read-only URI from a file path.
53+
* Converts Windows paths to proper URI format (e.g., c:\Users\... -> /c:/Users/...)
54+
*/
55+
public static createReadOnlyUri(filePath: string): vscode.Uri {
56+
// Use vscode.Uri.file() to properly convert the file path to URI format,
57+
// then replace the scheme with our read-only scheme
58+
const fileUri = vscode.Uri.file(filePath);
59+
return fileUri.with({ scheme: METADATA_DIFF_READONLY_SCHEME });
60+
}
61+
62+
watch(): vscode.Disposable {
63+
// No-op: we don't need to watch for changes on read-only files
64+
return new vscode.Disposable(() => { /* no-op */ });
65+
}
66+
67+
stat(uri: vscode.Uri): vscode.FileStat {
68+
// Use fsPath to get the platform-specific file system path
69+
const filePath = uri.fsPath;
70+
71+
if (!fs.existsSync(filePath)) {
72+
throw vscode.FileSystemError.FileNotFound(uri);
73+
}
74+
75+
const stats = fs.statSync(filePath);
76+
77+
return {
78+
type: stats.isDirectory() ? vscode.FileType.Directory : vscode.FileType.File,
79+
ctime: stats.ctimeMs,
80+
mtime: stats.mtimeMs,
81+
size: stats.size,
82+
permissions: vscode.FilePermission.Readonly
83+
};
84+
}
85+
86+
readDirectory(): [string, vscode.FileType][] {
87+
// Not needed for diff view
88+
return [];
89+
}
90+
91+
createDirectory(): void {
92+
throw vscode.FileSystemError.NoPermissions("Cannot create directory: read-only file system");
93+
}
94+
95+
readFile(uri: vscode.Uri): Uint8Array {
96+
// Use fsPath to get the platform-specific file system path
97+
const filePath = uri.fsPath;
98+
99+
if (!fs.existsSync(filePath)) {
100+
throw vscode.FileSystemError.FileNotFound(uri);
101+
}
102+
103+
return fs.readFileSync(filePath);
104+
}
105+
106+
writeFile(): void {
107+
throw vscode.FileSystemError.NoPermissions("Cannot write: read-only file system");
108+
}
109+
110+
delete(): void {
111+
throw vscode.FileSystemError.NoPermissions("Cannot delete: read-only file system");
112+
}
113+
114+
rename(): void {
115+
throw vscode.FileSystemError.NoPermissions("Cannot rename: read-only file system");
116+
}
117+
118+
public dispose(): void {
119+
this._disposable?.dispose();
120+
this._onDidChangeFile.dispose();
121+
}
122+
}

src/client/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { traceInfo } from "../../TelemetryHelper";
99
import { Constants } from "../../Constants";
1010
import { FileComparisonStatus, IFileComparisonResult } from "../../models/IFileComparisonResult";
1111
import { isBinaryFile } from "../../ActionsHubUtils";
12+
import { ReadOnlyContentProvider } from "../../ReadOnlyContentProvider";
1213

1314
/**
1415
* Opens all file diffs in the multi-diff editor for a specific site
@@ -49,9 +50,10 @@ export async function openAllMetadataDiffs(siteItem: MetadataDiffSiteTreeItem):
4950
// Show the multi-diff editor only for text files
5051
if (textFiles.length > 0) {
5152
// Create resource list for the changes editor
53+
// Original (remote) files use a read-only URI scheme to prevent accidental modifications
5254
const resourceList: [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined][] = textFiles.map(result => {
5355
const labelUri = vscode.Uri.parse(`diff-label:${result.relativePath}`);
54-
const originalUri = vscode.Uri.file(result.remotePath);
56+
const originalUri = ReadOnlyContentProvider.createReadOnlyUri(result.remotePath);
5557
const modifiedUri = vscode.Uri.file(result.localPath);
5658

5759
if (result.status === FileComparisonStatus.DELETED) {

src/client/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { traceInfo } from "../../TelemetryHelper";
1010
import { Constants } from "../../Constants";
1111
import { FileComparisonStatus } from "../../models/IFileComparisonResult";
1212
import { isBinaryFile } from "../../ActionsHubUtils";
13+
import { ReadOnlyContentProvider } from "../../ReadOnlyContentProvider";
1314

1415
/**
1516
* Opens a single file diff in the VS Code diff editor
@@ -38,25 +39,27 @@ export async function openMetadataDiffFile(fileItem: MetadataDiffFileTreeItem):
3839
return;
3940
}
4041

41-
// Handle different diff scenarios based on file status
42+
// Handle different scenarios based on file status
43+
// For added/deleted files, show single file view (like git)
44+
// For modified files, show diff with remote as read-only
4245
if (comparisonResult.status === FileComparisonStatus.DELETED) {
43-
// File exists in remote but not locally - show remote on left, empty on right
44-
const remoteUri = vscode.Uri.file(comparisonResult.remotePath);
46+
// File exists in remote but not locally - show remote file only (read-only)
47+
const remoteUri = ReadOnlyContentProvider.createReadOnlyUri(comparisonResult.remotePath);
4548

4649
if (fs.existsSync(comparisonResult.remotePath)) {
47-
// Show the remote file only (since local doesn't exist)
48-
await vscode.commands.executeCommand("vscode.diff", remoteUri, vscode.Uri.parse("untitled:"), title);
50+
await vscode.commands.executeCommand("vscode.open", remoteUri);
4951
}
5052
} else if (comparisonResult.status === FileComparisonStatus.ADDED) {
51-
// File exists locally but not in remote - show empty on left, local on right
53+
// File exists locally but not in remote - show local file only
5254
const localUri = vscode.Uri.file(comparisonResult.localPath);
5355

5456
if (fs.existsSync(comparisonResult.localPath)) {
55-
await vscode.commands.executeCommand("vscode.diff", vscode.Uri.parse("untitled:"), localUri, title);
57+
await vscode.commands.executeCommand("vscode.open", localUri);
5658
}
5759
} else {
5860
// Modified - both exist, show standard diff
59-
const remoteUri = vscode.Uri.file(comparisonResult.remotePath);
61+
// Remote (left side) is read-only, local (right side) is editable
62+
const remoteUri = ReadOnlyContentProvider.createReadOnlyUri(comparisonResult.remotePath);
6063
const localUri = vscode.Uri.file(comparisonResult.localPath);
6164

6265
if (fs.existsSync(comparisonResult.remotePath) && fs.existsSync(comparisonResult.localPath)) {
@@ -73,9 +76,9 @@ export async function openMetadataDiffFile(fileItem: MetadataDiffFileTreeItem):
7376
*/
7477
async function openBinaryFile(localPath: string, remotePath: string, status: string): Promise<void> {
7578
if (status === FileComparisonStatus.DELETED) {
76-
// For deleted files, the file only exists in remote
79+
// For deleted files, the file only exists in remote (read-only)
7780
if (fs.existsSync(remotePath)) {
78-
await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(remotePath));
81+
await vscode.commands.executeCommand("vscode.open", ReadOnlyContentProvider.createReadOnlyUri(remotePath));
7982
}
8083
} else if (status === FileComparisonStatus.ADDED) {
8184
// For added files, open the local version
@@ -84,17 +87,18 @@ async function openBinaryFile(localPath: string, remotePath: string, status: str
8487
}
8588
} else {
8689
// For modified files, open both remote and local side by side for visual comparison
90+
// Remote file is read-only
8791
if (fs.existsSync(remotePath) && fs.existsSync(localPath)) {
88-
// Open remote file in the first editor group (left side)
89-
await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(remotePath), vscode.ViewColumn.One);
92+
// Open remote file in the first editor group (left side) - read-only
93+
await vscode.commands.executeCommand("vscode.open", ReadOnlyContentProvider.createReadOnlyUri(remotePath), vscode.ViewColumn.One);
9094
// Open local file in the second editor group (right side)
9195
await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(localPath), vscode.ViewColumn.Two);
9296
} else if (fs.existsSync(localPath)) {
9397
// Fallback: if only local exists, just open it
9498
await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(localPath));
9599
} else if (fs.existsSync(remotePath)) {
96-
// Fallback: if only remote exists, just open it
97-
await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(remotePath));
100+
// Fallback: if only remote exists, just open it (read-only)
101+
await vscode.commands.executeCommand("vscode.open", ReadOnlyContentProvider.createReadOnlyUri(remotePath));
98102
}
99103
}
100104
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*/
5+
6+
import { expect } from "chai";
7+
import * as vscode from "vscode";
8+
import { ReadOnlyContentProvider, METADATA_DIFF_READONLY_SCHEME } from "../../../../power-pages/actions-hub/ReadOnlyContentProvider";
9+
10+
describe("ReadOnlyContentProvider", () => {
11+
describe("getInstance", () => {
12+
it("should return the same instance on multiple calls", () => {
13+
const instance1 = ReadOnlyContentProvider.getInstance();
14+
const instance2 = ReadOnlyContentProvider.getInstance();
15+
16+
expect(instance1).to.equal(instance2);
17+
});
18+
});
19+
20+
describe("createReadOnlyUri", () => {
21+
it("should create URI with the correct scheme", () => {
22+
const filePath = "/path/to/file.txt";
23+
const uri = ReadOnlyContentProvider.createReadOnlyUri(filePath);
24+
25+
expect(uri.scheme).to.equal(METADATA_DIFF_READONLY_SCHEME);
26+
});
27+
28+
it("should preserve the file path in the URI", () => {
29+
const filePath = "/path/to/file.txt";
30+
const uri = ReadOnlyContentProvider.createReadOnlyUri(filePath);
31+
32+
expect(uri.fsPath).to.include("file.txt");
33+
});
34+
35+
it("should handle Windows paths correctly", () => {
36+
const filePath = "C:\\Users\\test\\file.txt";
37+
const uri = ReadOnlyContentProvider.createReadOnlyUri(filePath);
38+
39+
expect(uri.scheme).to.equal(METADATA_DIFF_READONLY_SCHEME);
40+
// The path should be properly converted to URI format
41+
expect(uri.path).to.match(/^\/[cC]:\//);
42+
});
43+
44+
it("should handle paths with spaces", () => {
45+
const filePath = "/path/to/my file.txt";
46+
const uri = ReadOnlyContentProvider.createReadOnlyUri(filePath);
47+
48+
expect(uri.scheme).to.equal(METADATA_DIFF_READONLY_SCHEME);
49+
expect(uri.fsPath).to.include("my file.txt");
50+
});
51+
});
52+
53+
describe("write operations", () => {
54+
it("should throw NoPermissions on writeFile", () => {
55+
const provider = ReadOnlyContentProvider.getInstance();
56+
57+
expect(() => provider.writeFile()).to.throw();
58+
});
59+
60+
it("should throw NoPermissions on delete", () => {
61+
const provider = ReadOnlyContentProvider.getInstance();
62+
63+
expect(() => provider.delete()).to.throw();
64+
});
65+
66+
it("should throw NoPermissions on rename", () => {
67+
const provider = ReadOnlyContentProvider.getInstance();
68+
69+
expect(() => provider.rename()).to.throw();
70+
});
71+
72+
it("should throw NoPermissions on createDirectory", () => {
73+
const provider = ReadOnlyContentProvider.getInstance();
74+
75+
expect(() => provider.createDirectory()).to.throw();
76+
});
77+
});
78+
79+
describe("watch", () => {
80+
it("should return a Disposable", () => {
81+
const provider = ReadOnlyContentProvider.getInstance();
82+
83+
const disposable = provider.watch();
84+
85+
expect(disposable).to.be.instanceOf(vscode.Disposable);
86+
});
87+
});
88+
89+
describe("METADATA_DIFF_READONLY_SCHEME", () => {
90+
it("should be the expected scheme value", () => {
91+
expect(METADATA_DIFF_READONLY_SCHEME).to.equal("pp-metadata-diff-readonly");
92+
});
93+
});
94+
});

src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { isBinaryFile } from "../../../../../../power-pages/actions-hub/ActionsH
1111
import { MetadataDiffSiteTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem";
1212
import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper";
1313
import { IFileComparisonResult, ISiteComparisonResults } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult";
14+
import { METADATA_DIFF_READONLY_SCHEME } from "../../../../../../power-pages/actions-hub/ReadOnlyContentProvider";
1415

1516
/**
1617
* Helper function to create ISiteComparisonResults for testing
@@ -319,5 +320,47 @@ describe("OpenAllMetadataDiffsHandler", () => {
319320
textFilesIncluded: "1"
320321
});
321322
});
323+
324+
it("should use read-only scheme for original (remote) URIs", async () => {
325+
const results: IFileComparisonResult[] = [
326+
{
327+
localPath: "/local/file.txt",
328+
remotePath: "/remote/file.txt",
329+
relativePath: "file.txt",
330+
status: "modified"
331+
}
332+
];
333+
const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
334+
335+
await openAllMetadataDiffs(siteItem);
336+
337+
const resourceList = executeCommandStub.firstCall.args[2];
338+
const [, originalUri, modifiedUri] = resourceList[0];
339+
340+
// Original (remote) file should use read-only scheme
341+
expect(originalUri.scheme).to.equal(METADATA_DIFF_READONLY_SCHEME);
342+
// Modified (local) file should use regular file scheme
343+
expect(modifiedUri.scheme).to.equal("file");
344+
});
345+
346+
it("should use read-only scheme for deleted files original URI", async () => {
347+
const results: IFileComparisonResult[] = [
348+
{
349+
localPath: "/local/deleted.txt",
350+
remotePath: "/remote/deleted.txt",
351+
relativePath: "deleted.txt",
352+
status: "deleted"
353+
}
354+
];
355+
const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
356+
357+
await openAllMetadataDiffs(siteItem);
358+
359+
const resourceList = executeCommandStub.firstCall.args[2];
360+
const [, originalUri] = resourceList[0];
361+
362+
// Deleted file's original (remote) URI should use read-only scheme
363+
expect(originalUri.scheme).to.equal(METADATA_DIFF_READONLY_SCHEME);
364+
});
322365
});
323366
});

0 commit comments

Comments
 (0)