apple2js/js/components/debugger/Disks.tsx

423 lines
14 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>
);
};