apple2js/js/components/debugger/Disks.tsx

480 lines
15 KiB
TypeScript

import { h, Fragment } from 'preact';
import { useMemo } from 'preact/hooks';
import cs from 'classnames';
import { Apple2 as Apple2Impl } from 'js/apple2';
import {
BlockDisk,
DiskFormat,
DriveNumber,
FloppyDisk,
isBlockDiskFormat,
isNibbleDisk,
MassStorage,
} from 'js/formats/types';
import { slot } from 'js/apple2io';
import DiskII from 'js/cards/disk2';
import SmartPort from 'js/cards/smartport';
import createDiskFrom2MG from 'js/formats/2mg';
import createBlockDisk from 'js/formats/block';
import { ProDOSVolume } from 'js/formats/prodos';
import { FILE_TYPES, STORAGE_TYPES } from 'js/formats/prodos/constants';
import { Directory } from 'js/formats/prodos/directory';
import { FileEntry } from 'js/formats/prodos/file_entry';
import { VDH } from 'js/formats/prodos/vdh';
import { toHex } from 'js/util';
import styles from './css/Disks.module.scss';
import debuggerStyles from './css/Debugger.module.scss';
import { useCallback, useState } from 'preact/hooks';
import {
DOS33,
FileEntry as DOSEntry,
isMaybeDOS33,
} from 'js/formats/dos/dos33';
import createDiskFromDOS from 'js/formats/do';
import { FileData, FileViewer } from './FileViewer';
/**
* Formats a short date string
*
* @param date Data object
* @returns Short string date
*/
const formatDate = (date: Date) => {
return date.toLocaleString(undefined, {
dateStyle: 'short',
timeStyle: 'short',
});
};
/**
* Guard for determining whether a disk is a nibble or block based disk
*
* @param disk NibbleDisk or BlockDisk
* @returns true if is BlockDisk
*/
function isBlockDisk(disk: FloppyDisk | BlockDisk): disk is BlockDisk {
return !!(disk as BlockDisk).blocks;
}
/**
* Props for FileListing component
*/
interface FileListingProps {
volume: ProDOSVolume;
fileEntry: FileEntry;
depth: number;
setFileData: (fileData: FileData) => void;
}
/**
* Renders a ProDOS file entry.
*
* @param depth Depth of listing from root
* @param fileEntry ProDOS file entry to display
* @returns FileListing component
*/
const FileListing = ({ depth, fileEntry, setFileData }: FileListingProps) => {
const deleted = fileEntry.storageType === STORAGE_TYPES.DELETED;
const doSetFileData = useCallback(() => {
const binary = fileEntry.getFileData();
const text = fileEntry.getFileText();
if (binary && text) {
setFileData({
binary,
text,
fileName: fileEntry.name,
});
}
}, [fileEntry, setFileData]);
return (
<tr>
<td
className={cs(styles.filename, { [styles.deleted]: deleted })}
title={fileEntry.name}
onClick={doSetFileData}
>
{'| '.repeat(depth)}
{deleted ? (
<i className="fas fa-file-circle-xmark" />
) : (
<i className="fas fa-file" />
)}{' '}
{fileEntry.name}
</td>
<td>
{FILE_TYPES[fileEntry.fileType] ??
`$${toHex(fileEntry.fileType)}`}
</td>
<td>{`$${toHex(fileEntry.auxType, 4)}`}</td>
<td>{fileEntry.blocksUsed}</td>
<td>{formatDate(fileEntry.creation)}</td>
<td>{formatDate(fileEntry.lastMod)}</td>
</tr>
);
};
/**
* Props for DirectoryListing Component.
*/
interface DirectoryListingProps {
volume: ProDOSVolume;
dirEntry: VDH | Directory;
depth: number;
setFileData: (fileData: FileData) => void;
}
/**
* Displays information about a ProDOS directory, recursing through child
* directories.
*
* @param volume ProDOS volume
* @param depth Current directory depth
* @param dirEntry Current directory entry to display
* @returns DirectoryListing component
*/
const DirectoryListing = ({
volume,
depth,
dirEntry,
setFileData,
}: DirectoryListingProps) => {
const [open, setOpen] = useState(depth === 0);
return (
<>
<tr>
<td
className={styles.filename}
onClick={() => setOpen((open) => !open)}
title={dirEntry.name}
>
{'| '.repeat(depth)}
<i
className={cs('fas', {
'fa-folder-open': open,
'fa-folder-closed': !open,
})}
/>{' '}
{dirEntry.name}
</td>
<td></td>
<td></td>
<td></td>
<td>{formatDate(dirEntry.creation)}</td>
<td></td>
</tr>
{open &&
dirEntry.entries.map((fileEntry, idx) => {
if (fileEntry.storageType === STORAGE_TYPES.DIRECTORY) {
const dirEntry = new Directory(volume, fileEntry);
return (
<DirectoryListing
key={idx}
depth={depth + 1}
volume={volume}
dirEntry={dirEntry}
setFileData={setFileData}
/>
);
} else {
return (
<FileListing
key={idx}
depth={depth + 1}
volume={volume}
fileEntry={fileEntry}
setFileData={setFileData}
/>
);
}
})}
</>
);
};
/**
* Props for CatalogEntry component
*/
interface CatalogEntryProps {
dos: DOS33;
fileEntry: DOSEntry;
setFileData: (fileData: FileData) => void;
}
/**
* Component for a single DOS 3.x catalog entry
*
* @param entry Catalog entry to display
* @returns CatalogEntry component
*/
const CatalogEntry = ({ dos, fileEntry, setFileData }: CatalogEntryProps) => {
const doSetFileData = useCallback(() => {
const { data } = dos.readFile(fileEntry);
setFileData({
binary: data,
text: dos.dumpFile(fileEntry),
fileName: fileEntry.name,
});
}, [dos, fileEntry, setFileData]);
return (
<tr onClick={doSetFileData}>
<td
className={cs(styles.filename, {
[styles.deleted]: fileEntry.deleted,
})}
>
{fileEntry.locked && <i className="fas fa-lock" />}{' '}
{fileEntry.name}
</td>
<td>{fileEntry.type}</td>
<td>{fileEntry.size}</td>
<td></td>
</tr>
);
};
/**
* Catalog component props
*/
interface CatalogProps {
dos: DOS33;
setFileData: (fileData: FileData) => void;
}
/**
* DOS 3.3 disk catalog component
*
* @param dos DOS 3.3 disk object
* @returns Catalog component
*/
const Catalog = ({ dos, setFileData }: CatalogProps) => {
const catalog = useMemo(() => dos.readCatalog(), [dos]);
return (
<>
{catalog.map((fileEntry, idx) => (
<CatalogEntry
key={idx}
dos={dos}
fileEntry={fileEntry}
setFileData={setFileData}
/>
))}
</>
);
};
/**
* Props for DiskInfo component
*/
interface DiskInfoProps {
massStorage: MassStorage<DiskFormat>;
driveNo: DriveNumber;
setFileData: (fileData: FileData) => void;
}
/**
* Top level disk info component, handles determining what sort of disk
* is present and using the appropriate sub-component depending on whether
* it's a ProDOS block disk or a DOS 3.3 disk.
*
* TODO(whscullin): Does not handle woz or 13 sector.
*
* @param massStorage The storage device
* @param drive The drive number
* @returns DiskInfo component
*/
const DiskInfo = ({ massStorage, driveNo, setFileData }: DiskInfoProps) => {
const disk = useMemo(() => {
const massStorageData = massStorage.getBinary(driveNo, 'po');
if (massStorageData) {
const { data, readOnly, ext } = massStorageData;
const { name } = massStorageData.metadata;
let disk: BlockDisk | FloppyDisk | null = null;
if (ext === '2mg') {
disk = createDiskFrom2MG({
name,
rawData: data,
readOnly,
volume: 254,
});
} else if (data.byteLength < 800 * 1024) {
const doData = massStorage.getBinary(driveNo, 'do');
if (doData) {
if (isMaybeDOS33(doData)) {
disk = createDiskFromDOS({
name,
rawData: doData.data,
readOnly,
volume: 254,
});
}
}
}
if (!disk && isBlockDiskFormat(ext)) {
disk = createBlockDisk(ext, {
name,
rawData: data,
readOnly,
volume: 254,
});
}
return disk;
}
return null;
}, [massStorage, driveNo]);
if (disk) {
try {
if (isBlockDisk(disk)) {
if (disk.blocks.length) {
const prodos = new ProDOSVolume(disk);
const { totalBlocks } = prodos.vdh();
const freeCount = prodos.bitMap().freeBlocks().length;
const usedCount = totalBlocks - freeCount;
return (
<div className={styles.volume}>
<table>
<thead>
<tr>
<th className={styles.filename}>
Filename
</th>
<th className={styles.type}>Type</th>
<th className={styles.aux}>Aux</th>
<th className={styles.blocks}>
Blocks
</th>
<th className={styles.created}>
Created
</th>
<th className={styles.modified}>
Modified
</th>
</tr>
</thead>
<tbody>
<DirectoryListing
depth={0}
volume={prodos}
dirEntry={prodos.vdh()}
setFileData={setFileData}
/>
</tbody>
<tfoot>
<tr>
<td colSpan={1}>
Blocks Free: {freeCount}
</td>
<td colSpan={3}>Used: {usedCount}</td>
<td colSpan={2}>
Total: {totalBlocks}
</td>
</tr>
</tfoot>
</table>
</div>
);
}
} else if (isNibbleDisk(disk)) {
const dos = new DOS33(disk);
return (
<div className={styles.volume}>
<table>
<thead>
<tr>
<th className={styles.filename}>
Filename
</th>
<th className={styles.type}>Type</th>
<th className={styles.sectors}>Sectors</th>
<th></th>
</tr>
</thead>
<tbody>
<Catalog dos={dos} setFileData={setFileData} />
</tbody>
<tfoot>
<tr>
<td>Volume Number:</td>
<td colSpan={3}>{dos.getVolumeNumber()}</td>
</tr>
<tr>
<td>Used Sectors:</td>
<td colSpan={3}>{dos.usedSectorCount()}</td>
</tr>
<tr>
<td>Free Sectors:</td>
<td colSpan={3}>{dos.freeSectorCount()}</td>
</tr>
</tfoot>
</table>
</div>
);
}
} catch (error) {
console.error(error);
return <pre>Unknown volume</pre>;
}
}
return <pre>No disk</pre>;
};
/**
* Disks component props
*/
export interface DisksProps {
apple2: Apple2Impl;
}
/**
* A debugger panel that displays information about currently mounted
* disks.
*
* @param apple2 The apple2 object
* @returns Disks component
*/
export const Disks = ({ apple2 }: DisksProps) => {
const [fileData, setFileData] = useState<FileData | null>(null);
const io = apple2.getIO();
const cards: MassStorage<DiskFormat>[] = [];
const onClose = useCallback(() => {
setFileData(null);
}, []);
for (let idx = 0; idx <= 7; idx++) {
const card = io.getSlot(idx as slot);
if (card instanceof DiskII || card instanceof SmartPort) {
cards.push(card);
}
}
return (
<div>
{cards.map((card, idx) => (
<div key={idx}>
<div className={debuggerStyles.subHeading}>
{card.constructor.name} - 1
</div>
<DiskInfo
massStorage={card}
driveNo={1}
setFileData={setFileData}
/>
<div className={debuggerStyles.subHeading}>
{card.constructor.name} - 2
</div>
<DiskInfo
massStorage={card}
driveNo={2}
setFileData={setFileData}
/>
</div>
))}
<FileViewer fileData={fileData} onClose={onClose} />
</div>
);
};