feat: F4.6g4 the Gnutella window, measured against Java's
GnutellaWindowRun uses Java's Gnutella window on terminal-net beside the line of GnutellaRun's story "search". The core cancels a search as Java does (every connection of the program's own requests closed, passed-on queries kept), lets the number of peers change and lists /peer2peer's files; the room takes the window's actions and sends its view; the editor shows Java's four tabs with Java's symbols. Not adopted from Java: a number of peers of one digit only, a results table that keeps the last search's rows, a files table that lags behind the folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
// A computer's Gnutella program, after Java's
|
||||
// GUIApplicationPeerToPeerAnwendungWindow (measured with tools/java-oracle's
|
||||
// GnutellaWindowRun): four tabs with Java's symbols. "Netzwerk": a peer's
|
||||
// address (it starts as Java's, 192.168.0.1; red while it is no address)
|
||||
// with "Netz beitreten", the table of known peers, "Netz verlassen".
|
||||
// "Suche": the words with "Suchen" and "Stopp!", the table of results (a row
|
||||
// chosen), "Herunterladen" and "Liste leeren". "Dateien": the files of
|
||||
// /peer2peer. "Einstellungen": how many peers it keeps at most, with
|
||||
// "Speichern". The program is the room's: the panel shows its view and hands
|
||||
// over what is done.
|
||||
//
|
||||
// Better than Java:
|
||||
// - the tables show what the program keeps at once: Java's results still
|
||||
// show the last search's after a new one until a hit comes, and its files
|
||||
// only what was there when the program last told the window of a change.
|
||||
// - any whole number of peers is taken (Java's check takes one digit only).
|
||||
// - what is not taken is said under its field (Java opens a dialog), and
|
||||
// "Herunterladen" can be pressed only with a result chosen.
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { gnutellaPeerCountOfText, IP_ADDRESS_PATTERN } from '@mdg-filius/core';
|
||||
import type { GnutellaView } from '@mdg-filius/protocol';
|
||||
import networkIcon from '../assets/desktop/peertopeer_netzwerk_klein.png';
|
||||
import searchIcon from '../assets/desktop/peertopeer_suchen_klein.png';
|
||||
import filesIcon from '../assets/desktop/peertopeer_dateien_klein.png';
|
||||
import settingsIcon from '../assets/desktop/peertopeer_einstellungen.png';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import type { Simulation } from '../simulation/useSimulation.js';
|
||||
|
||||
type Tab = 'network' | 'search' | 'files' | 'settings';
|
||||
const TABS: readonly (readonly [Tab, string, string])[] = [
|
||||
['network', 'gnutella.networkTab', networkIcon],
|
||||
['search', 'gnutella.searchTab', searchIcon],
|
||||
['files', 'gnutella.filesTab', filesIcon],
|
||||
['settings', 'gnutella.settingsTab', settingsIcon],
|
||||
];
|
||||
|
||||
export function GnutellaPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
|
||||
const t = useTranslate();
|
||||
const topic = `app:${node}/gnutella` as const;
|
||||
const { watch, act } = simulation;
|
||||
useEffect(() => watch([topic]), [watch, topic]);
|
||||
const view = simulation.views.get(topic) as GnutellaView | null | undefined;
|
||||
const [tab, setTab] = useState<Tab>('network');
|
||||
const [address, setAddress] = useState('192.168.0.1');
|
||||
const [criteria, setCriteria] = useState('');
|
||||
const [chosen, setChosen] = useState<number | null>(null);
|
||||
const [maximum, setMaximum] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const results = JSON.stringify(view?.results ?? []);
|
||||
// Java's table is filled anew with each change and loses its choice.
|
||||
useEffect(() => setChosen(null), [results]);
|
||||
if (view === null || view === undefined) return <div className="web-server" />;
|
||||
const addressValid = IP_ADDRESS_PATTERN.test(address);
|
||||
const maximumText = maximum ?? String(view.maximumKnownPeers);
|
||||
const count = gnutellaPeerCountOfText(maximumText);
|
||||
|
||||
const join = () => {
|
||||
if (!addressValid) {
|
||||
setNotice(t('gnutella.invalidAddress'));
|
||||
return;
|
||||
}
|
||||
act(node, 'gnutella', 'join', { address });
|
||||
setNotice(null);
|
||||
};
|
||||
const search = () => act(node, 'gnutella', 'search', { criteria });
|
||||
const save = () => {
|
||||
if (count === undefined) {
|
||||
setNotice(t('gnutella.onlyNumbers'));
|
||||
return;
|
||||
}
|
||||
act(node, 'gnutella', 'setMaximumPeers', { count });
|
||||
setMaximum(null);
|
||||
setNotice(null);
|
||||
};
|
||||
const noticeLine =
|
||||
notice === null ? null : (
|
||||
<p className="mail-notice" role="alert">
|
||||
{notice}
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="web-server gnutella">
|
||||
<div className="mail-tabs" role="tablist">
|
||||
{TABS.map(([each, label, icon]) => (
|
||||
<button
|
||||
key={each}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === each}
|
||||
onClick={() => {
|
||||
setTab(each);
|
||||
setNotice(null);
|
||||
}}
|
||||
>
|
||||
<img src={icon} alt="" />
|
||||
{t(label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'network' ? (
|
||||
<div className="gnutella-page" role="tabpanel" aria-label={t('gnutella.networkTab')}>
|
||||
<div className="web-server-bar">
|
||||
<label>
|
||||
{t('gnutella.address')}
|
||||
<input
|
||||
value={address}
|
||||
inputMode="decimal"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={addressValid ? undefined : 'true'}
|
||||
onChange={(event) => {
|
||||
setAddress(event.target.value);
|
||||
setNotice(null);
|
||||
}}
|
||||
onKeyDown={(event) => event.key === 'Enter' && join()}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" title={t('gnutella.joinHint')} onClick={join}>
|
||||
{t('gnutella.join')}
|
||||
</button>
|
||||
</div>
|
||||
{noticeLine}
|
||||
<p className="gnutella-label">{t('gnutella.neighbours')}</p>
|
||||
<table className="dns-server-table gnutella-table" aria-label={t('gnutella.neighbours')}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('gnutella.number')}</th>
|
||||
<th>{t('gnutella.addressColumn')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.peers.map((peer, index) => (
|
||||
<tr key={peer}>
|
||||
<td>{index + 1}</td>
|
||||
<td>{peer}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="web-server-bar">
|
||||
<button type="button" title={t('gnutella.leaveHint')} onClick={() => act(node, 'gnutella', 'leave')}>
|
||||
{t('gnutella.leave')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : tab === 'search' ? (
|
||||
<div className="gnutella-page" role="tabpanel" aria-label={t('gnutella.searchTab')}>
|
||||
<div className="web-server-bar">
|
||||
<label>
|
||||
{t('gnutella.searchTerm')}
|
||||
<input
|
||||
value={criteria}
|
||||
title={t('gnutella.searchHint')}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => setCriteria(event.target.value)}
|
||||
onKeyDown={(event) => event.key === 'Enter' && search()}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" onClick={search}>
|
||||
{t('gnutella.search')}
|
||||
</button>
|
||||
<button type="button" title={t('gnutella.stopHint')} onClick={() => act(node, 'gnutella', 'cancelSearch')}>
|
||||
{t('gnutella.stop')}
|
||||
</button>
|
||||
</div>
|
||||
<table className="dns-server-table gnutella-table" aria-label={t('gnutella.searchTab')}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('gnutella.resultAddress')}</th>
|
||||
<th>{t('gnutella.resultName')}</th>
|
||||
<th>{t('gnutella.resultSize')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.results.map((cells, row) => (
|
||||
<tr
|
||||
key={row}
|
||||
tabIndex={0}
|
||||
aria-selected={row === chosen}
|
||||
onPointerDown={() => setChosen(row)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
setChosen(row);
|
||||
}}
|
||||
>
|
||||
{cells.map((cell, column) => (
|
||||
<td key={column}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="web-server-bar">
|
||||
<button type="button" title={t('gnutella.downloadHint')} disabled={chosen === null} onClick={() => chosen !== null && act(node, 'gnutella', 'download', { row: chosen })}>
|
||||
{t('gnutella.download')}
|
||||
</button>
|
||||
<button type="button" title={t('gnutella.clearHint')} onClick={() => act(node, 'gnutella', 'clearResults')}>
|
||||
{t('gnutella.clear')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : tab === 'files' ? (
|
||||
<div className="gnutella-page" role="tabpanel" aria-label={t('gnutella.filesTab')}>
|
||||
<p className="gnutella-label">{t('gnutella.files')}</p>
|
||||
<table className="dns-server-table gnutella-table" aria-label={t('gnutella.filesTab')}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('gnutella.fileName')}</th>
|
||||
<th>{t('gnutella.fileType')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.files.map(([name, type]) => (
|
||||
<tr key={name}>
|
||||
<td>{name}</td>
|
||||
<td>{type}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gnutella-page" role="tabpanel" aria-label={t('gnutella.settingsTab')}>
|
||||
<div className="web-server-bar">
|
||||
<label>
|
||||
{t('gnutella.maximum')}
|
||||
<input
|
||||
value={maximumText}
|
||||
inputMode="numeric"
|
||||
aria-invalid={count === undefined ? 'true' : undefined}
|
||||
onChange={(event) => {
|
||||
setMaximum(event.target.value);
|
||||
setNotice(null);
|
||||
}}
|
||||
onKeyDown={(event) => event.key === 'Enter' && save()}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" onClick={save}>
|
||||
{t('gnutella.save')}
|
||||
</button>
|
||||
</div>
|
||||
{noticeLine}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user