diff --git a/packages/zowe-explorer/CHANGELOG.md b/packages/zowe-explorer/CHANGELOG.md index 5c80b9b7c9..b4e0d7b6c9 100644 --- a/packages/zowe-explorer/CHANGELOG.md +++ b/packages/zowe-explorer/CHANGELOG.md @@ -36,6 +36,7 @@ All notable changes to the "vscode-extension-for-zowe" extension will be documen ### Bug fixes +- Fixed an issue where the Jobs FileSystemProvider could not resolve profiles from URIs when opened programmatically, causing errors when extensions tried to open job spool files directly. The Jobs FileSystemProvider now extracts the profile name from the URI and loads it on-demand, matching the behavior of Datasets and USS FileSystemProviders. [#4284](https://github.com/zowe/zowe-explorer-vscode/issues/4284) - Fixed an issue where Zowe Explorer failed to activate if a VS Code workspace is opened and contains an invalid directory path. Now, invalid directory paths are ignored by Zowe Explorer and only valid paths are treated as project-level directories. [#4271](https://github.com/zowe/zowe-explorer-vscode/issues/4271) - Fixed an issue where saving contents to a data set or USS file could trigger built-in conflict detection, specifically when the API does not include a timestamp for that resource. Now, the modification time of a data set or USS file in Zowe Explorer's filesystem is kept as-is if the API does not provide a timestamp. Users can continue to use the "Pull from Mainframe" context-menu option to fetch the latest contents of a data set or USS file in an opened editor. [#4206](https://github.com/zowe/zowe-explorer-vscode/issues/4206) - Updated USS and data set file system providers to generate notifications based on remote system changes rather than local file system cache updates. [#4162](https://github.com/zowe/zowe-explorer-vscode/pull/4162) diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/job/JobFSProvider.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/job/JobFSProvider.unit.test.ts index 5d2e9a6236..bb26cd725b 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/job/JobFSProvider.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/job/JobFSProvider.unit.test.ts @@ -696,6 +696,36 @@ describe("fetchSpoolAtUri", () => { jesApiMock.mockRestore(); lookupAsFileMock.mockRestore(); }); + + it("resolves profile from URI when metadata is missing", async () => { + const spoolEntryWithoutMetadata = { ...testEntries.spool, metadata: undefined }; + const lookupAsFileMock = vi.spyOn(JobFSProvider.instance as any, "_lookupAsFile").mockReturnValueOnce(spoolEntryWithoutMetadata); + const getInfoFromUriMock = vi.spyOn(JobFSProvider.instance as any, "_getInfoFromUri").mockReturnValue({ + profile: testProfile, + path: "/TESTJOB(JOB1234) - ACTIVE/JES2.JESMSGLG.2", + }); + + const mockJesApi = { + downloadSingleSpool: vi.fn((opts) => { + opts.stream.write("test data"); + }), + }; + + const jesApiMock = vi.spyOn(ZoweExplorerApiRegister, "getInstance").mockReturnValue({ + getJesApi: vi.fn().mockReturnValue(mockJesApi), + registeredJesApiTypes: vi.fn().mockReturnValue(["zosmf"]), + } as any); + + const entry = await JobFSProvider.instance.fetchSpoolAtUri(testUris.spool); + + expect(getInfoFromUriMock).toHaveBeenCalledWith(testUris.spool); + expect(entry.metadata).toBeDefined(); + expect(entry.metadata.profile).toBe(testProfile); + + lookupAsFileMock.mockRestore(); + getInfoFromUriMock.mockRestore(); + jesApiMock.mockRestore(); + }); }); describe("readFile", () => { @@ -718,6 +748,295 @@ describe("readFile", () => { lookupAsFileMock.mockRestore(); fetchSpoolAtUriMock.mockRestore(); }); + + it("creates entries from URI when spool entry doesn't exist", async () => { + const spoolEntry = { ...testEntries.spool }; + const fileNotFoundError = vscode.FileSystemError.FileNotFound(testUris.spool); + const lookupAsFileMock = vi + .spyOn(JobFSProvider.instance as any, "_lookupAsFile") + .mockImplementationOnce(() => { + throw fileNotFoundError; + }) + .mockReturnValueOnce(spoolEntry); + const createEntriesFromUriMock = vi.spyOn(JobFSProvider.instance as any, "_createEntriesFromUri").mockResolvedValueOnce(undefined); + const fetchSpoolAtUriMock = vi.spyOn(JobFSProvider.instance, "fetchSpoolAtUri").mockResolvedValueOnce(spoolEntry); + + expect(await JobFSProvider.instance.readFile(testUris.spool)).toBe(spoolEntry.data); + expect(createEntriesFromUriMock).toHaveBeenCalledWith(testUris.spool); + expect(spoolEntry.wasAccessed).toBe(true); + + lookupAsFileMock.mockRestore(); + createEntriesFromUriMock.mockRestore(); + fetchSpoolAtUriMock.mockRestore(); + }); + + it("throws error if entry creation fails", async () => { + const fileNotFoundError = vscode.FileSystemError.FileNotFound(testUris.spool); + const lookupAsFileMock = vi.spyOn(JobFSProvider.instance as any, "_lookupAsFile").mockImplementation(() => { + throw fileNotFoundError; + }); + const createEntriesFromUriMock = vi + .spyOn(JobFSProvider.instance as any, "_createEntriesFromUri") + .mockRejectedValueOnce(new Error("Failed to create entries")); + + await expect(JobFSProvider.instance.readFile(testUris.spool)).rejects.toThrow("Failed to create entries"); + + lookupAsFileMock.mockRestore(); + createEntriesFromUriMock.mockRestore(); + }); + + it("throws non-FileNotFound errors immediately without creating entries", async () => { + const customError = new Error("Custom error that is not FileNotFound"); + const lookupAsFileMock = vi.spyOn(JobFSProvider.instance as any, "_lookupAsFile").mockImplementation(() => { + throw customError; + }); + const createEntriesFromUriMock = vi.spyOn(JobFSProvider.instance as any, "_createEntriesFromUri"); + + await expect(JobFSProvider.instance.readFile(testUris.spool)).rejects.toThrow("Custom error that is not FileNotFound"); + expect(createEntriesFromUriMock).not.toHaveBeenCalled(); + + lookupAsFileMock.mockRestore(); + createEntriesFromUriMock.mockRestore(); + }); +}); + +describe("_createEntriesFromUri", () => { + const mockJob = createIJobObject(); + const mockSpoolFile = createIJobFile(); + // buildUniqueSpoolName will create: TESTJOB.JOB1234.STEP.STDOUT.101 + const testJobUri = Uri.from({ scheme: ZoweScheme.Jobs, path: "/sestest/JOB1234/TESTJOB.JOB1234.STEP.STDOUT.101" }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("throws FileNotFound error when URI has insufficient path parts", async () => { + const invalidUri = Uri.from({ scheme: ZoweScheme.Jobs, path: "/sestest/JOB1234" }); + const getInfoFromUriMock = vi.spyOn(JobFSProvider.instance as any, "_getInfoFromUri").mockReturnValue({ + profile: testProfile, + path: "/JOB1234", + }); + + await expect((JobFSProvider.instance as any)._createEntriesFromUri(invalidUri)).rejects.toThrow(); + + getInfoFromUriMock.mockRestore(); + }); + + it("creates profile directory when it doesn't exist", async () => { + const getInfoFromUriMock = vi.spyOn(JobFSProvider.instance as any, "_getInfoFromUri").mockReturnValue({ + profile: testProfile, + path: "/JOB1234/TESTJOB.JOB1234.STEP.STDOUT.101", + }); + const existsMock = vi.spyOn(JobFSProvider.instance, "exists").mockReturnValue(false); + const createDirectoryMock = vi.spyOn(JobFSProvider.instance, "createDirectory").mockImplementation(() => undefined); + const jobEntryWithSpools = { + ...testEntries.job, + entries: new Map(), + }; + const lookupAsDirMock = vi.spyOn(JobFSProvider.instance as any, "_lookupAsDirectory").mockReturnValue(jobEntryWithSpools); + + const mockJesApi = { + getJobsByParameters: vi.fn().mockResolvedValue([mockJob]), + getSpoolFiles: vi.fn().mockResolvedValue([mockSpoolFile]), + }; + const jesApiMock = vi.spyOn(ZoweExplorerApiRegister, "getInstance").mockReturnValue({ + getJesApi: vi.fn().mockReturnValue(mockJesApi), + registeredJesApiTypes: vi.fn().mockReturnValue(["zosmf"]), + } as any); + const ensureAuthNotCancelledMock = vi.spyOn(AuthUtils, "ensureAuthNotCancelled").mockResolvedValue(undefined); + const waitForUnlockMock = vi.spyOn(AuthHandler, "waitForUnlock").mockResolvedValue(undefined); + + // Mock writeFile to add the spool entry to the job's entries map + const writeFileMock = vi.spyOn(JobFSProvider.instance, "writeFile").mockImplementation((uri, content, options: any) => { + if (options?.name) { + jobEntryWithSpools.entries.set(options.name, testEntries.spool); + } + }); + + await (JobFSProvider.instance as any)._createEntriesFromUri(testJobUri); + + expect(createDirectoryMock).toHaveBeenCalledWith(expect.objectContaining({ path: "/sestest" }), expect.objectContaining({ isFilter: true })); + + getInfoFromUriMock.mockRestore(); + existsMock.mockRestore(); + createDirectoryMock.mockRestore(); + lookupAsDirMock.mockRestore(); + jesApiMock.mockRestore(); + ensureAuthNotCancelledMock.mockRestore(); + waitForUnlockMock.mockRestore(); + writeFileMock.mockRestore(); + }); + + it("creates job directory and fetches job information when job doesn't exist", async () => { + const getInfoFromUriMock = vi.spyOn(JobFSProvider.instance as any, "_getInfoFromUri").mockReturnValue({ + profile: testProfile, + path: "/JOB1234/TESTJOB.JOB1234.STEP.STDOUT.101", + }); + const existsMock = vi + .spyOn(JobFSProvider.instance, "exists") + .mockReturnValueOnce(true) // profile exists + .mockReturnValueOnce(false); // job doesn't exist + const createDirectoryMock = vi.spyOn(JobFSProvider.instance, "createDirectory").mockImplementation(() => undefined); + const jobEntryWithSpools = { + ...testEntries.job, + entries: new Map(), + }; + const lookupAsDirMock = vi.spyOn(JobFSProvider.instance as any, "_lookupAsDirectory").mockReturnValue(jobEntryWithSpools); + + const mockJesApi = { + getJobsByParameters: vi.fn().mockResolvedValue([mockJob]), + getSpoolFiles: vi.fn().mockResolvedValue([mockSpoolFile]), + }; + const jesApiMock = vi.spyOn(ZoweExplorerApiRegister, "getInstance").mockReturnValue({ + getJesApi: vi.fn().mockReturnValue(mockJesApi), + registeredJesApiTypes: vi.fn().mockReturnValue(["zosmf"]), + } as any); + const ensureAuthNotCancelledMock = vi.spyOn(AuthUtils, "ensureAuthNotCancelled").mockResolvedValue(undefined); + const waitForUnlockMock = vi.spyOn(AuthHandler, "waitForUnlock").mockResolvedValue(undefined); + + // Mock writeFile to add the spool entry to the job's entries map + const writeFileMock = vi.spyOn(JobFSProvider.instance, "writeFile").mockImplementation((uri, content, options: any) => { + if (options?.name) { + jobEntryWithSpools.entries.set(options.name, testEntries.spool); + } + }); + + await (JobFSProvider.instance as any)._createEntriesFromUri(testJobUri); + + expect(mockJesApi.getJobsByParameters).toHaveBeenCalledWith({ jobid: "JOB1234" }); + expect(createDirectoryMock).toHaveBeenCalledWith( + expect.objectContaining({ path: "/sestest/JOB1234" }), + expect.objectContaining({ job: mockJob }) + ); + expect(ensureAuthNotCancelledMock).toHaveBeenCalledWith(testProfile); + expect(waitForUnlockMock).toHaveBeenCalledWith(testProfile); + + getInfoFromUriMock.mockRestore(); + existsMock.mockRestore(); + createDirectoryMock.mockRestore(); + lookupAsDirMock.mockRestore(); + jesApiMock.mockRestore(); + ensureAuthNotCancelledMock.mockRestore(); + waitForUnlockMock.mockRestore(); + writeFileMock.mockRestore(); + }); + + it("throws FileNotFound error when job is not found on mainframe", async () => { + const getInfoFromUriMock = vi.spyOn(JobFSProvider.instance as any, "_getInfoFromUri").mockReturnValue({ + profile: testProfile, + path: "/JOB1234/TESTJOB.JOB1234.STEP.STDOUT.101", + }); + const existsMock = vi + .spyOn(JobFSProvider.instance, "exists") + .mockReturnValueOnce(true) // profile exists + .mockReturnValueOnce(false); // job doesn't exist + + const mockJesApi = { + getJobsByParameters: vi.fn().mockResolvedValue([]), // No jobs found + }; + const jesApiMock = vi.spyOn(ZoweExplorerApiRegister, "getInstance").mockReturnValue({ + getJesApi: vi.fn().mockReturnValue(mockJesApi), + registeredJesApiTypes: vi.fn().mockReturnValue(["zosmf"]), + } as any); + const ensureAuthNotCancelledMock = vi.spyOn(AuthUtils, "ensureAuthNotCancelled").mockResolvedValue(undefined); + const waitForUnlockMock = vi.spyOn(AuthHandler, "waitForUnlock").mockResolvedValue(undefined); + + await expect((JobFSProvider.instance as any)._createEntriesFromUri(testJobUri)).rejects.toThrow(); + + getInfoFromUriMock.mockRestore(); + existsMock.mockRestore(); + jesApiMock.mockRestore(); + ensureAuthNotCancelledMock.mockRestore(); + waitForUnlockMock.mockRestore(); + }); + + it("fetches spool files when job entry has no entries", async () => { + const getInfoFromUriMock = vi.spyOn(JobFSProvider.instance as any, "_getInfoFromUri").mockReturnValue({ + profile: testProfile, + path: "/JOB1234/TESTJOB.JOB1234.STEP.STDOUT.101", + }); + const existsMock = vi.spyOn(JobFSProvider.instance, "exists").mockReturnValue(true); + const jobEntryWithNoSpools = { + ...testEntries.job, + entries: new Map(), + }; + const lookupAsDirMock = vi.spyOn(JobFSProvider.instance as any, "_lookupAsDirectory").mockReturnValue(jobEntryWithNoSpools); + + const mockJesApi = { + getSpoolFiles: vi.fn().mockResolvedValue([mockSpoolFile]), + }; + const jesApiMock = vi.spyOn(ZoweExplorerApiRegister, "getInstance").mockReturnValue({ + getJesApi: vi.fn().mockReturnValue(mockJesApi), + registeredJesApiTypes: vi.fn().mockReturnValue(["zosmf"]), + } as any); + + // Mock writeFile to add the spool entry to the job's entries map with the correct name + const writeFileMock = vi.spyOn(JobFSProvider.instance, "writeFile").mockImplementation((uri, content, options: any) => { + if (options?.name) { + jobEntryWithNoSpools.entries.set(options.name, testEntries.spool); + } + }); + + await (JobFSProvider.instance as any)._createEntriesFromUri(testJobUri); + + expect(mockJesApi.getSpoolFiles).toHaveBeenCalledWith(mockJob.jobname, mockJob.jobid); + expect(writeFileMock).toHaveBeenCalled(); + + getInfoFromUriMock.mockRestore(); + existsMock.mockRestore(); + lookupAsDirMock.mockRestore(); + jesApiMock.mockRestore(); + writeFileMock.mockRestore(); + }); + + it("skips fetching spool files when job entry already has entries", async () => { + const getInfoFromUriMock = vi.spyOn(JobFSProvider.instance as any, "_getInfoFromUri").mockReturnValue({ + profile: testProfile, + path: "/JOB1234/TESTJOB.JOB1234.STEP.STDOUT.101", + }); + const existsMock = vi.spyOn(JobFSProvider.instance, "exists").mockReturnValue(true); + const jobEntryWithSpools = { + ...testEntries.job, + entries: new Map([["TESTJOB.JOB1234.STEP.STDOUT.101", testEntries.spool]]), + }; + const lookupAsDirMock = vi.spyOn(JobFSProvider.instance as any, "_lookupAsDirectory").mockReturnValue(jobEntryWithSpools); + + const mockJesApi = { + getSpoolFiles: vi.fn().mockResolvedValue([mockSpoolFile]), + }; + const jesApiMock = vi.spyOn(ZoweExplorerApiRegister, "getInstance").mockReturnValue({ + getJesApi: vi.fn().mockReturnValue(mockJesApi), + registeredJesApiTypes: vi.fn().mockReturnValue(["zosmf"]), + } as any); + + await (JobFSProvider.instance as any)._createEntriesFromUri(testJobUri); + + expect(mockJesApi.getSpoolFiles).not.toHaveBeenCalled(); + + getInfoFromUriMock.mockRestore(); + existsMock.mockRestore(); + lookupAsDirMock.mockRestore(); + jesApiMock.mockRestore(); + }); + + it("throws FileNotFound error when spool file is not found in job entries", async () => { + const getInfoFromUriMock = vi.spyOn(JobFSProvider.instance as any, "_getInfoFromUri").mockReturnValue({ + profile: testProfile, + path: "/JOB1234/NONEXISTENT.SPOOL.FILE", + }); + const existsMock = vi.spyOn(JobFSProvider.instance, "exists").mockReturnValue(true); + const jobEntryWithDifferentSpool = { + ...testEntries.job, + entries: new Map([["DIFFERENT.SPOOL.NAME", testEntries.spool]]), + }; + const lookupAsDirMock = vi.spyOn(JobFSProvider.instance as any, "_lookupAsDirectory").mockReturnValue(jobEntryWithDifferentSpool); + + await expect((JobFSProvider.instance as any)._createEntriesFromUri(testJobUri)).rejects.toThrow(); + + getInfoFromUriMock.mockRestore(); + existsMock.mockRestore(); + lookupAsDirMock.mockRestore(); + }); }); describe("writeFile", () => { diff --git a/packages/zowe-explorer/l10n/bundle.l10n.json b/packages/zowe-explorer/l10n/bundle.l10n.json index 452922f6f2..d9644020d4 100644 --- a/packages/zowe-explorer/l10n/bundle.l10n.json +++ b/packages/zowe-explorer/l10n/bundle.l10n.json @@ -1107,6 +1107,7 @@ "Fixed an issue where the filesystem continued to use a profile with invalid credentials to fetch resources. Now, after an authentication error occurs for a profile, it cannot be used again in the filesystem until the authentication error is resolved. #3329": "Fixed an issue where the filesystem continued to use a profile with invalid credentials to fetch resources. Now, after an authentication error occurs for a profile, it cannot be used again in the filesystem until the authentication error is resolved. #3329", "Fixed an issue where the first change to a Zowe team configuration did not accurately refresh the data set, USS and job trees. #3524": "Fixed an issue where the first change to a Zowe team configuration did not accurately refresh the data set, USS and job trees. #3524", "Fixed an issue where the job spool pagination code lens was appearing even though the extender did not support pagination. #3714": "Fixed an issue where the job spool pagination code lens was appearing even though the extender did not support pagination. #3714", + "Fixed an issue where the Jobs FileSystemProvider could not resolve profiles from URIs when opened programmatically, causing errors when extensions tried to open job spool files directly. The Jobs FileSystemProvider now extracts the profile name from the URI and loads it on-demand, matching the behavior of Datasets and USS FileSystemProviders. #4284": "Fixed an issue where the Jobs FileSystemProvider could not resolve profiles from URIs when opened programmatically, causing errors when extensions tried to open job spool files directly. The Jobs FileSystemProvider now extracts the profile name from the URI and loads it on-demand, matching the behavior of Datasets and USS FileSystemProviders. #4284", "Fixed an issue where the location prompt for the Create Directory and Create File USS features would appear even when a path is already set for the profile or parent folder. #3183": "Fixed an issue where the location prompt for the Create Directory and Create File USS features would appear even when a path is already set for the profile or parent folder. #3183", "Fixed an issue where the mismatch etag error returned was not triggering the diff editor, resulting in possible loss of data due to the issue. #2277": "Fixed an issue where the mismatch etag error returned was not triggering the diff editor, resulting in possible loss of data due to the issue. #2277", "Fixed an issue where the onProfilesUpdate event did not fire after secure credentials were updated. #2822": "Fixed an issue where the onProfilesUpdate event did not fire after secure credentials were updated. #2822", @@ -1425,6 +1426,7 @@ "Job enhancements": "Job enhancements", "Job ID": "Job ID", "Job Name": "Job Name", + "Job not found: {0}": "Job not found: {0}", "Job polling will automatically check active jobs for status changes at regular intervals. You will be notified when jobs complete. You can stop polling at any time by running this command again.": "Job polling will automatically check active jobs for status changes at regular intervals. You will be notified when jobs complete. You can stop polling at any time by running this command again.", "Job search cancelled.": "Job search cancelled.", "Job spool pagination": "Job spool pagination", @@ -2077,6 +2079,7 @@ }, "Specify another codepage": "Specify another codepage", "Specify the data set type (DSNTYPE)": "Specify the data set type (DSNTYPE)", + "Spool file not found: {0}": "Spool file not found: {0}", "SSH profile missing connection details. Please update.": "SSH profile missing connection details. Please update.", "Starting to poll job {0} for completion./Job display name": { "message": "Starting to poll job {0} for completion.", diff --git a/packages/zowe-explorer/l10n/poeditor.json b/packages/zowe-explorer/l10n/poeditor.json index ab21dba7cc..c13c9924b2 100644 --- a/packages/zowe-explorer/l10n/poeditor.json +++ b/packages/zowe-explorer/l10n/poeditor.json @@ -1271,6 +1271,7 @@ "Fixed an issue where the filesystem continued to use a profile with invalid credentials to fetch resources. Now, after an authentication error occurs for a profile, it cannot be used again in the filesystem until the authentication error is resolved. #3329": "", "Fixed an issue where the first change to a Zowe team configuration did not accurately refresh the data set, USS and job trees. #3524": "", "Fixed an issue where the job spool pagination code lens was appearing even though the extender did not support pagination. #3714": "", + "Fixed an issue where the Jobs FileSystemProvider could not resolve profiles from URIs when opened programmatically, causing errors when extensions tried to open job spool files directly. The Jobs FileSystemProvider now extracts the profile name from the URI and loads it on-demand, matching the behavior of Datasets and USS FileSystemProviders. #4284": "", "Fixed an issue where the location prompt for the Create Directory and Create File USS features would appear even when a path is already set for the profile or parent folder. #3183": "", "Fixed an issue where the mismatch etag error returned was not triggering the diff editor, resulting in possible loss of data due to the issue. #2277": "", "Fixed an issue where the onProfilesUpdate event did not fire after secure credentials were updated. #2822": "", @@ -1522,6 +1523,7 @@ "Job enhancements": "", "Job ID": "", "Job Name": "", + "Job not found: {0}": "", "Job polling will automatically check active jobs for status changes at regular intervals. You will be notified when jobs complete. You can stop polling at any time by running this command again.": "", "Job search cancelled.": "", "Job spool pagination": "", @@ -1894,6 +1896,7 @@ "Sorting updated for {0}": "", "Specify another codepage": "", "Specify the data set type (DSNTYPE)": "", + "Spool file not found: {0}": "", "SSH profile missing connection details. Please update.": "", "Starting to poll job {0} for completion.": "", "Status": "", diff --git a/packages/zowe-explorer/src/trees/job/JobFSProvider.ts b/packages/zowe-explorer/src/trees/job/JobFSProvider.ts index b1b5c10400..d118803728 100644 --- a/packages/zowe-explorer/src/trees/job/JobFSProvider.ts +++ b/packages/zowe-explorer/src/trees/job/JobFSProvider.ts @@ -228,17 +228,18 @@ export class JobFSProvider extends BaseProvider implements vscode.FileSystemProv const bufBuilder = new BufferBuilder(); const metadata = spoolEntry.metadata ?? this._getInfoFromUri(uri); + // Assign metadata to the entry if it was resolved from URI + spoolEntry.metadata ??= metadata; const profile = Profiles.getInstance().loadNamedProfile(metadata.profile.name); const profileEncoding = spoolEntry.encoding ? null : profile.profile?.encoding; // use profile encoding rather than metadata encoding const apiRegister = ZoweExplorerApiRegister.getInstance(); - const jesApi = FsAbstractUtils.getApiOrThrowUnavailable( - spoolEntry.metadata.profile, - () => apiRegister.getJesApi(spoolEntry.metadata.profile), - { apiName: vscode.l10n.t("JES API"), registeredTypes: apiRegister.registeredJesApiTypes() } - ); + const jesApi = FsAbstractUtils.getApiOrThrowUnavailable(metadata.profile, () => apiRegister.getJesApi(metadata.profile), { + apiName: vscode.l10n.t("JES API"), + registeredTypes: apiRegister.registeredJesApiTypes(), + }); await AuthUtils.ensureAuthNotCancelled(profile); - await AuthHandler.waitForUnlock(spoolEntry.metadata.profile); + await AuthHandler.waitForUnlock(metadata.profile); const query = new URLSearchParams(uri.query); let recordRange = ""; const recordsToFetch = SettingsConfig.getDirectValue("zowe.jobs.paginate.recordsToFetch") ?? 0; @@ -298,7 +299,18 @@ export class JobFSProvider extends BaseProvider implements vscode.FileSystemProv * @returns The spool file's contents as an array of bytes */ public async readFile(uri: vscode.Uri): Promise { - const spoolEntry = this._lookupAsFile(uri) as SpoolEntry; + let spoolEntry: SpoolEntry; + try { + spoolEntry = this._lookupAsFile(uri) as SpoolEntry; + } catch (err) { + if (!(err instanceof vscode.FileSystemError) || err.code !== "FileNotFound") { + throw err; + } + // Entry doesn't exist - need to create it from URI + await this._createEntriesFromUri(uri); + spoolEntry = this._lookupAsFile(uri) as SpoolEntry; + } + if (!spoolEntry.wasAccessed) { await this.fetchSpoolAtUri(uri); spoolEntry.wasAccessed = true; @@ -413,4 +425,84 @@ export class JobFSProvider extends BaseProvider implements vscode.FileSystemProv path: uriInfo.isRoot ? "/" : uri.path.substring(uriInfo.slashAfterProfilePos), }; } + + /** + * Creates the necessary directory and file entries in the FileSystem from a URI. + * This is used when a URI is opened programmatically without going through the tree. + * @param uri The URI to create entries for (format: zowe-jobs:/profileName/jobId/spoolName) + */ + private async _createEntriesFromUri(uri: vscode.Uri): Promise { + const metadata = this._getInfoFromUri(uri); + const pathParts = metadata.path.split("/").filter(Boolean); + + // URI format: /jobId/spoolName + if (pathParts.length < 2) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const jobId = pathParts[0]; + const spoolName = pathParts[1]; + + // Create profile directory if it doesn't exist + const profileUri = uri.with({ path: `/${metadata.profile.name}` }); + if (!this.exists(profileUri)) { + this.createDirectory(profileUri, { isFilter: true }); + } + + // Create job directory if it doesn't exist + const jobUri = uri.with({ path: `/${metadata.profile.name}/${jobId}` }); + let jobEntry: JobEntry; + if (!this.exists(jobUri)) { + // Fetch job information from the mainframe + const apiRegister = ZoweExplorerApiRegister.getInstance(); + const jesApi = FsAbstractUtils.getApiOrThrowUnavailable(metadata.profile, () => apiRegister.getJesApi(metadata.profile), { + apiName: vscode.l10n.t("JES API"), + registeredTypes: apiRegister.registeredJesApiTypes(), + }); + + await AuthUtils.ensureAuthNotCancelled(metadata.profile); + await AuthHandler.waitForUnlock(metadata.profile); + + // Get job by job ID + let job: IJob; + await AuthUtils.retryRequest(metadata.profile, async () => { + const jobs = await jesApi.getJobsByParameters({ jobid: jobId }); + if (!jobs || jobs.length === 0) { + throw vscode.FileSystemError.FileNotFound(vscode.l10n.t("Job not found: {0}", jobId)); + } + job = jobs[0]; + }); + this.createDirectory(jobUri, { job }); + } + jobEntry = this._lookupAsDirectory(jobUri, false) as JobEntry; + + // Fetch spool files for the job if not already loaded + if (jobEntry.entries.size === 0) { + const apiRegister = ZoweExplorerApiRegister.getInstance(); + const jesApi = FsAbstractUtils.getApiOrThrowUnavailable(metadata.profile, () => apiRegister.getJesApi(metadata.profile), { + apiName: vscode.l10n.t("JES API"), + registeredTypes: apiRegister.registeredJesApiTypes(), + }); + + await AuthUtils.retryRequest(metadata.profile, async () => { + const spoolFiles = await jesApi.getSpoolFiles(jobEntry.job.jobname, jobEntry.job.jobid); + for (const spool of spoolFiles) { + const uniqueSpoolName = FsJobsUtils.buildUniqueSpoolName(spool); + if (!jobEntry.entries.has(uniqueSpoolName)) { + this.writeFile(uri.with({ path: path.posix.join(jobUri.path, uniqueSpoolName) }), new Uint8Array(), { + create: true, + overwrite: false, + name: uniqueSpoolName, + spool, + }); + } + } + }); + } + + // Verify the spool entry exists + if (!jobEntry.entries.has(spoolName)) { + throw vscode.FileSystemError.FileNotFound(vscode.l10n.t("Spool file not found: {0}", spoolName)); + } + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b1fa3828f..ebef0147d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,7 +219,7 @@ importers: version: 17.0.3 '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.6(@types/debug@4.1.12)(@types/node@20.19.42)(@vitest/ui@3.2.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 3.2.4(vitest@3.2.6(@types/debug@4.1.12)(@types/node@22.15.34)(@vitest/ui@3.2.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) '@wdio/cli': specifier: ^8.41.0 version: 8.44.0(encoding@0.1.13) @@ -273,10 +273,10 @@ importers: version: 19.0.2 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.42)(typescript@5.9.3) + version: 10.9.2(@types/node@22.15.34)(typescript@5.9.3) vitest: specifier: ^3.2.4 - version: 3.2.6(@types/debug@4.1.12)(@types/node@20.19.42)(@vitest/ui@3.2.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.6(@types/debug@4.1.12)(@types/node@22.15.34)(@vitest/ui@3.2.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) wdio-vscode-service: specifier: ^6.1.3 version: 6.1.3(webdriverio@8.43.0(encoding@0.1.13)) @@ -1529,157 +1529,131 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-gnueabihf@4.61.1': resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm-musleabihf@4.61.1': resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-gnu@4.61.1': resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-musl@4.61.1': resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-gnu@4.61.1': resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-musl@4.61.1': resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.61.1': resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-musl@4.61.1': resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.61.1': resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-musl@4.61.1': resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.61.1': resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.61.1': resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-linux-x64-musl@4.61.1': resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -1930,9 +1904,6 @@ packages: '@types/node@20.19.33': resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} - '@types/node@20.19.42': - resolution: {integrity: sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==} - '@types/node@22.14.0': resolution: {integrity: sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==} @@ -8894,10 +8865,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@20.19.42': - dependencies: - undici-types: 6.21.0 - '@types/node@22.14.0': dependencies: undici-types: 6.21.0 @@ -9093,25 +9060,6 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.6(@types/debug@4.1.12)(@types/node@20.19.42)(@vitest/ui@3.2.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@ampproject/remapping': 2.3.0 - '@bcoe/v8-coverage': 1.0.2 - ast-v8-to-istanbul: 0.3.12 - debug: 4.4.3(supports-color@8.1.1) - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.1.7 - magic-string: 0.30.21 - magicast: 0.3.5 - std-env: 3.10.0 - test-exclude: 7.0.2 - tinyrainbow: 2.0.0 - vitest: 3.2.6(@types/debug@4.1.12)(@types/node@20.19.42)(@vitest/ui@3.2.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.6(@types/debug@4.1.12)(@types/node@22.15.34)(@vitest/ui@3.2.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@ampproject/remapping': 2.3.0 @@ -9166,14 +9114,6 @@ snapshots: optionalDependencies: vite: 7.3.5(@types/node@20.19.33)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@20.19.42)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@vitest/spy': 3.2.6 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.5(@types/node@20.19.42)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@22.15.34)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.6 @@ -14722,14 +14662,14 @@ snapshots: ts-graphviz@1.8.2: {} - ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3): + ts-node@10.9.2(@types/node@22.15.34)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.19.42 + '@types/node': 22.15.34 acorn: 8.11.3 acorn-walk: 8.3.2 arg: 4.1.3 @@ -14965,27 +14905,6 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@20.19.42)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 7.3.5(@types/node@20.19.42)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vite-node@3.2.4(@types/node@22.15.34)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: cac: 6.7.14 @@ -15071,21 +14990,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vite@7.3.5(@types/node@20.19.42)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - esbuild: 0.27.7 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 - rollup: 4.61.1 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 20.19.42 - fsevents: 2.3.3 - terser: 5.46.0 - tsx: 4.21.0 - yaml: 2.8.3 - vite@7.3.5(@types/node@22.15.34)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.7 @@ -15144,49 +15048,6 @@ snapshots: - tsx - yaml - vitest@3.2.6(@types/debug@4.1.12)(@types/node@20.19.42)(@vitest/ui@3.2.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.6 - '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@20.19.42)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/pretty-format': 3.2.6 - '@vitest/runner': 3.2.6 - '@vitest/snapshot': 3.2.6 - '@vitest/spy': 3.2.6 - '@vitest/utils': 3.2.6 - chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) - expect-type: 1.3.0 - magic-string: 0.30.21 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.17 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.3.5(@types/node@20.19.42)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@20.19.42)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/debug': 4.1.12 - '@types/node': 20.19.42 - '@vitest/ui': 3.2.4(vitest@3.2.6) - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vitest@3.2.6(@types/debug@4.1.12)(@types/node@22.15.34)(@vitest/ui@3.2.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3