Mainframe Commander
Total Commander-style dual-pane file management for z/OS, as a VS Code extension.
The connection goes through Zowe, so the user's existing
zowe.config.json — certificates, MFA and credential manager included — is the
only setup required.
The design sketch is in docs/mainframe-commander-mockup.html.
Getting started
npm install
npm run build # or: npm run watch
Press F5 in VS Code to start an Extension Development Host, and run the
command Mainframe Commander: Open (Ctrl+Shift+Alt+M). F1 in the panel lists
every shortcut; the list itself is SHORTCUTS in
webview/keymap.ts, next to the bindings it describes.
npm run typecheck runs TypeScript over both the extension and the webview
half, and npm test runs the unit tests. Both run in CI on Linux and
Windows, and again before vsce packages anything.
npm run test:host runs the providers against a real z/OSMF, through the
default Zowe profile (or MC_HOST_PROFILE), which CI has no way to reach. It
writes members into the PDS named by MC_HOST_PDS, and files into the USS
directory named by MC_HOST_USS_DIR — only names starting with MCT or mct,
all removed again at the end, and never the data set or directory itself — and
reads JES without changing anything. Either of the two may be left out, and its
tests are skipped. MC_HOST_DEBUG=1 shows every request.
MC_HOST_PDS=USER.SCRATCH MC_HOST_USS_DIR=/u/user/scratch npm run test:host
In Git Bash, prefix it with MSYS_NO_PATHCONV=1, or the USS path arrives as a
Windows one.
npm run vsix # -> mainframe-commander-<version>.vsix
One package covers every platform: the only native code is the Zowe credential
manager, and it ships a prebuilt binary for each of the eleven targets in the
same npm package — so there is no vsce package --target per architecture.
scripts/package-vsix.mjs checks that before
handing over to vsce, because the credential manager is the one package that
cannot be bundled: a package built without it in node_modules installs
perfectly and then reads every secure value in zowe.config.json as empty.
How it fits together
src/
├─ extension.ts Activation: registers providers, commands and file system
├─ commanderPanel.ts The webview panel and all state (where each pane points)
├─ shared/
│ └─ protocol.ts The message contract between host and webview — imported by both
├─ core/
│ ├─ provider.ts The PaneProvider interface + registry
│ ├─ cursorHistory.ts Which row the cursor was on, per listing
│ ├─ ebcdic.ts Local EBCDIC decoding for Shift+F3
│ ├─ transferQueue.ts Background queue for F5: streams source into target, with cancellation
│ ├─ treeCopy.ts Turns F5 on folders into folders made and files queued
│ ├─ search.ts Alt+F7: walks down from a pane, matching names and text
│ ├─ spool.ts Temporary file for uploads that must be checked before they are sent
│ ├─ editorBridge.ts FileSystemProvider, so F3/F4 open in a real editor
│ ├─ text.ts Text/binary choice and LRECL fitting, whole or streamed
│ ├─ settings.ts Typed reading of the mc.* settings
│ ├─ log.ts The Output channel, filtered by mc.log.level
│ ├─ trace.ts Debug tracing of provider calls and their z/OSMF requests
│ └─ errors.ts Digs the readable sentence out of z/OSMF errors
├─ providers/
│ ├─ localProvider.ts Local disk
│ ├─ dsProvider.ts MVS: dataset filters, PDS members, allocation, submit
│ ├─ ussProvider.ts USS: paths, permissions, tagging
│ └─ jesProvider.ts JES: jobs as folders, spool DDs as files
└─ zowe/
├─ sessions.ts ProfileInfo → session, cached per profile name
└─ flow.ts Backpressure and cancellation for the SDK's streaming calls
webview/
├─ index.ts App: layout, messages, actions
├─ pane.ts One pane: header, path, rows, footer, selection
├─ virtualList.ts Windowed rendering — only visible rows are built
├─ keymap.ts Key → action, with dedup of forwarded keys, and the F1 list
├─ search.ts The Alt+F7 dialog, filled in as hits arrive
├─ dialogs.ts F1 help, F5 transfer, F7 allocation, Ctrl+F filter, Ctrl+D saved views, prompts
├─ vscode.ts The webview API handle; every request to the host goes through send()
├─ progress.ts The busy pointer — from the webview, the host and the transfer queue
└─ style.css Total Commander layout in the user's VS Code theme
The load-bearing idea
PaneProvider is the whole extension. Local disk, datasets, USS and JES
implement the same interface, so every key has exactly one implementation no
matter which world the pane is showing — and F5 from LPAR1 to LPAR2 is not a
special case, just source.readTo() streaming into target.writeFrom().
Decisions worth knowing
- Transfers stream, and are paced. F5 never holds a whole file: the source
writes into a stream the target reads from, so memory stays flat however big
the data set. The Zowe SDK streams but ignores backpressure in both
directions, so
zowe/flow.ts finds the HTTP request each call makes (through
Node's http.client.request.created channel and an AsyncLocalStorage set
around the call) and waits on it — which is also what makes cancelling a
running transfer actually stop it. The one exception is text going into a
data set with Long lines: Abort: the refused line can be the last one, so
the fitted records are spooled to a temporary file and sent only once all of
them fit. A local target is written beside the real name and renamed at the
end, so a failed copy never leaves half a file behind.
- A folder is whatever holds files. F5 on a directory, a PDS or a job
walks it and copies what is inside, so the same key copies a PDS to another
LPAR, a job's spool to a folder on disk, or a folder of JCL into a new PDS.
The walk happens before anything is queued: folders are made at the target
first — a PDS allocated with the original's organisation, record format,
length, block size and space, or, for a directory, as an FB 80 PDS/E sized
for its files — so each transfer only ever writes a file into a place that
exists. What cannot be copied is left out with a reason and the rest goes
ahead: a folder inside a PDS, a load library (z/OSMF moves records, not load
modules), two files that would become the same member, and anything that
would be copied onto or into itself.
- JES is a file system. Jobs are folders, spool DDs are files. That is why
F3/F5/F8 mean the same thing there as everywhere else, without a separate
command palette just for jobs.
- The JES filter is a dialog, not a path. Which jobs a JES pane shows comes
down to owner, job name and queue, and writing that as
owner=IBMUSER;prefix=BK*;status=output is a syntax to remember rather than a
question to answer. Ctrl+F — or a click on the path bar, which is showing
exactly those three things — asks for them as fields and hands the answer back
to the provider, which is still the only side that knows how a JES path is
spelled. The dialog opens on the resolved values, so the owner it shows is
the one being listed even when the path never said one. The fields themselves
are the provider's (Listing.filter), so the mechanism is not JES-specific:
any world that is a filter rather than a path can declare one and get the same
dialog. The four view tabs stay honest about the filter rather than beside it:
Mine and All claim to be every job of an owner, so they clear the job name
as well — a Mine that still hides everything but RACF* is not mine —
while Active and Output only claim a queue and keep what is in force. And
a tab is lit only when the pane is showing exactly what that tab points at, so
a filter typed into Ctrl+F that none of the four describes lights up none of
them instead of one that is not true.
- Saved views are favourites with a name.
Ctrl+D saves where a pane is
standing — LPAR, world and filter — under a name, and every saved view for the
world and profile a pane is in turns up as a ★ tab beside the provider's own
views, because they are the same kind of thing: a name and a place to stand.
They live in mc.favourites rather than in the extension's own storage: this
is the user saying "this is a view I want back", which belongs somewhere they
can read, edit and share, next to mc.panes.*. The name is the identity, so
saving over one replaces it, and the list is validated on the way in — it is
an array people will edit by hand.
- F7 asks for more than a name on MVS. RECFM, LRECL and the space cannot be
changed afterwards, so allocation has a real dialog with the four shapes it
actually comes down to (FB 80, FBA 133, VB 255, load module) — and a LIKE
field, because the answer is most often "like the dataset that already
exists". Inside a PDS and on USS/local disk, F7 is still just a name prompt.
- Dataset names are read as in TSO. A name without apostrophes is relative to
the user's own HLQ, so
TEST.JCL becomes IBMUSER.TEST.JCL; 'SYS1.PARMLIB'
is used exactly as typed. This holds for both F7 and F6, and both dialogs
therefore pre-fill with apostrophes — the field already carries a full
qualifier, which the user should not get their own put on top of.
- File system provider rather than temp files. F3/F4 open
mc://LPAR1/BACKUP01,
so syntax colours, diff, search and Ctrl+S work as on a local file. The
read-only views use schemes of their own — mc-view for F3 and mc-ebcdic
for Shift+F3 — because VS Code can only set readonly per provider.
- The codepage is picked, not typed, and is remembered.
IBM-277 is only
right in Denmark and Norway, so the F5 dialog offers the national EBCDIC pages
by country and writes the choice back to mc.transfer.codepage — into
whichever settings scope already defines it, so a workspace value is not
silently shadowed by a global one. The offered set is mc.transfer.codepages
for sites with a page of their own. It applies to every direction: F3/F4 read
with it, Ctrl+S writes with it, and F5 uses it at both ends — a file read in
one codepage and written back in another is how national characters get lost.
- The panes reopen where you left them.
mc.panes.* says where a pane
starts; after that the position that last listed successfully is what comes
back, so the PDS or USS directory you were working in — which LPAR it was on,
and the row the cursor was on — survives closing VS Code. Within a session
every listing keeps its own cursor, so stepping into a PDS and back out lands
on the member you came from rather than at the top. It is kept in the extension's own storage
rather than written back into mc.panes.*, because it changes on every
navigation and that settings file is often under git. The setting still wins
whenever it has been edited since: the position is remembered together with
the mc.panes.* value it was remembered against, so changing the setting is
never silently ignored.
Shift+F3 decodes the bytes here, not on the host. F3 asks z/OSMF for a
conversion, which only works for content the host agrees is text. A load
module, a data set read in binary, or an EBCDIC file that was FTP'd down to
the PC has no service left to convert it — so Shift+F3 fetches the raw bytes
and translates them locally, using IBM's own CDRA tables in
src/core/ebcdic.ts. Records come out one per line:
split on x'15' when the bytes carry one, otherwise on the data set's LRECL
(mc.view.ebcdicRecordLength when there is none). The page is
mc.view.ebcdicCodepage, falling back to the transfer codepage, and it is in
the tab title — the same bytes are equally valid as IBM-037 and as IBM-277,
so which one you chose is part of what you are looking at. Content that is
already text is refused rather than decoded: ASCII read as EBCDIC comes out as
pages of accented letters that look like a wrong codepage rather than like the
wrong question, and a USS file tagged ISO8859-1 — z/OSMF's own .properties
files are — has no EBCDIC in it at all.
longLines: 'abort' by default. A 132-character line going into FB 80 is
the classic way to ruin an upload. The user has to choose wrap or truncate
deliberately.
auto only guesses binary on known extensions. Treating an unknown file as
text is recoverable; treating it as binary silently ruins the EBCDIC
conversion.
- The Zowe SDKs are bundled; the credential manager is not. Left in
node_modules, the SDKs drag in some 5,000 files. Imperative's dynamic
require() calls — command handlers, plugins, custom credential managers —
all belong to the CLI and are never reached through ProfileInfo and the REST
client, so esbuild can take the rest. @zowe/secrets-for-zowe-sdk loads a
native .node binary, so it stays external in esbuild.mjs and is the only
runtime dependency; the SDKs themselves are devDependencies.
Status
1.0. All four worlds — local disk, MVS, USS and JES — are written against the
Zowe v8 API and tested against a real z/OSMF (IBM Z Xplore) with
npm run test:host, alongside the unit tests for the parts that can lose data
quietly: the EBCDIC tables, LRECL fitting, transfer-mode choice and error
parsing.
Known limitations:
- Recall of migrated data sets has not been tried against a real
DFSMShsm. Z Xplore has none for its users, so the recall — queued at HSM,
then checked on until the data set is back — is tested against a fake
z/OSMF only. Reports from a system with HSM are welcome.
- A directory with many thousands of entries lists slowly on some systems:
z/OSMF builds a listing one entry at a time, about 30 ms each on Z Xplore,
and gives up after 30 seconds. A pane asks only for
mc.list.pageSize
entries; lower it, or cd straight to where you are going.
- Transfers run in parallel. Concurrent listings were shown to collide over
the user's ISPF profile and are sent one at a time; concurrent reads were
measured not to, concurrent writes have not been measured.
Later, around 1.2:
- [ ] TSO and console commands on the command line. z/OSMF's TSO service
starts an address space of its own, which may collide with the file
services over the user's ISPF profile the way two file requests did
(
ISPT036), so this needs trying against a host before it is designed.
- [ ] Moving folders (F5 copies them; a move of a folder is refused)
If Mainframe Commander saves you time: github.com/sponsors/SMoRG75.
License
Copyright © 2026 ubi.dk - Søren Andersen. Released under EPL-2.0, like the rest of the Zowe ecosystem.
The extension bundles the Zowe SDKs (EPL-2.0) and their dependencies. Their
licenses, and where to get the Zowe source, are in dist/THIRD-PARTY-NOTICES.txt,
which every build regenerates and the .vsix includes.
| |