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:
2026-10-03 21:39:29 +00:00
co-authored by Claude Opus 5.5
parent 2b85e7432c
commit 5224e1a28b
24 changed files with 2211 additions and 17 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -42,6 +42,7 @@ import { FileExplorerPanel } from './FileExplorerPanel.js';
import { EchoServerPanel, SimpleClientPanel } from './EchoPanels.js';
import { DnsServerPanel } from './DnsServerPanel.js';
import { PersonalFirewallPanel } from './PersonalFirewallPanel.js';
import { GnutellaPanel } from './GnutellaPanel.js';
import { ImageViewerPanel, useImageViewer } from './ImageViewerPanel.js';
import { WebServerPanel } from './WebServerPanel.js';
import './desktop.css';
@@ -72,7 +73,7 @@ export interface DesktopWindowProps {
export type DesktopWindowName = DesktopProgram | 'installation';
/** The programs whose windows are built so far. */
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer', 'echoServer', 'simpleClient', 'dnsServer', 'firewall']);
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer', 'echoServer', 'simpleClient', 'dnsServer', 'firewall', 'gnutella']);
/** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */
function useDesktopView(doc: Y.Doc, id: string) {
@@ -191,6 +192,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
{shown === 'simpleClient' ? <SimpleClientPanel node={id} simulation={simulation} /> : null}
{shown === 'dnsServer' ? <DnsServerPanel node={id} simulation={simulation} /> : null}
{shown === 'firewall' ? <PersonalFirewallPanel node={id} simulation={simulation} /> : null}
{shown === 'gnutella' ? <GnutellaPanel node={id} simulation={simulation} /> : null}
</ProgramFrame>
)}
{info ? (
@@ -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>
);
}
+49
View File
@@ -1202,3 +1202,52 @@
.personal-firewall-table tr {
cursor: pointer;
}
/* Gnutella (GUIApplicationPeerToPeerAnwendungWindow): four tabs with Java's
* symbols — the network, the search, the files, the settings. */
.gnutella-page {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.gnutella .mail-tabs button {
display: inline-flex;
align-items: center;
gap: 4px;
}
.gnutella .mail-tabs img {
width: 24px;
height: 24px;
}
.gnutella-label {
margin: 0;
}
.gnutella input {
min-height: 32px;
padding: 0 6px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
font: 16px Dialog, sans-serif;
}
.gnutella [aria-invalid='true'] {
border-color: #ff0000;
color: #ff1414;
}
.gnutella-table td {
padding: 6px;
overflow-wrap: anywhere;
}
.gnutella-table tbody tr[tabindex] {
cursor: pointer;
}
+31
View File
@@ -220,6 +220,37 @@
"forwarding.lanPortValue": "LAN-Port",
"forwarding.protocolValue": "Protokoll",
"forwarding.publicPortValue": "Portfreigabe",
"gnutella.address": "Teilnehmer IP-Adresse:",
"gnutella.addressColumn": "Adresse",
"gnutella.clear": "Liste leeren",
"gnutella.clearHint": "Entfernt die bisherigen Suchergebnisse",
"gnutella.download": "Herunterladen",
"gnutella.downloadHint": "Herunterladen der markierten Datei",
"gnutella.fileName": "Dateiname",
"gnutella.fileType": "Dateityp",
"gnutella.files": "Dateien:",
"gnutella.filesTab": "Dateien",
"gnutella.invalidAddress": "Dies ist keine gültige IP-Adresse",
"gnutella.join": "Netz beitreten",
"gnutella.joinHint": "Versucht einem bestehenden Netzwerk beizutreten, nach Eingabe der IP-Adresse eines Teilnehmers.",
"gnutella.leave": "Netz verlassen",
"gnutella.leaveHint": "Zurücksetzen der Teilnehmerliste",
"gnutella.maximum": "Maximale Anzahl direkter Nachbarn:",
"gnutella.neighbours": "Liste der verbundenen Nachbarn:",
"gnutella.networkTab": "Netzwerk",
"gnutella.number": "Kennung",
"gnutella.onlyNumbers": "Es sind nur Zahlen erlaubt!",
"gnutella.resultAddress": "IP-Adresse",
"gnutella.resultName": "Dateiname",
"gnutella.resultSize": "Größe",
"gnutella.save": "Speichern",
"gnutella.search": "Suchen",
"gnutella.searchHint": "Suche nach enthaltener Zeichenkette",
"gnutella.searchTab": "Suche",
"gnutella.searchTerm": "Suchbegriff:",
"gnutella.settingsTab": "Einstellungen",
"gnutella.stop": "Stopp!",
"gnutella.stopHint": "Stoppt die laufende Suche",
"installation.add": "Installieren",
"installation.apply": "Änderungen annehmen",
"installation.available": "Verfügbar:",
+31
View File
@@ -220,6 +220,37 @@
"forwarding.lanPortValue": "LAN Port",
"forwarding.protocolValue": "Protocol",
"forwarding.publicPortValue": "Mapped Port",
"gnutella.address": "Member IP address:",
"gnutella.addressColumn": "address",
"gnutella.clear": "Clear list",
"gnutella.clearHint": "Clear results",
"gnutella.download": "Download",
"gnutella.downloadHint": "Downloads selected file",
"gnutella.fileName": "File",
"gnutella.fileType": "File type",
"gnutella.files": "Files:",
"gnutella.filesTab": "Files",
"gnutella.invalidAddress": "Invalid IP address.",
"gnutella.join": "Enter network",
"gnutella.joinHint": "Enter an existing network.",
"gnutella.leave": "Leave network",
"gnutella.leaveHint": "Clear list of connected neighbours.",
"gnutella.maximum": "Maximal number of direct neighbours:",
"gnutella.neighbours": "List of connected neighbours.",
"gnutella.networkTab": "Network",
"gnutella.number": "Identification",
"gnutella.onlyNumbers": "Just numbers are allowed!",
"gnutella.resultAddress": "IP address",
"gnutella.resultName": "File",
"gnutella.resultSize": "Size",
"gnutella.save": "Save",
"gnutella.search": "Search",
"gnutella.searchHint": "Search for substring",
"gnutella.searchTab": "Search",
"gnutella.searchTerm": "Search item:",
"gnutella.settingsTab": "Settings",
"gnutella.stop": "Stop!",
"gnutella.stopHint": "Stop current search",
"installation.add": "Install",
"installation.apply": "Apply changes",
"installation.available": "Available:",
+2 -1
View File
@@ -7,7 +7,7 @@
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import * as Y from 'yjs';
import { desktopView, deviceSheet, emptyProject, fileText, topologyView, writeProject } from '@mdg-filius/doc';
import { changeFiles, desktopView, deviceSheet, emptyProject, fileText, topologyView, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import { startBridge } from './bridge/dashboardBridge.js';
import { Editor } from './Editor.js';
@@ -70,6 +70,7 @@ function App() {
sheet: (id: string) => deviceSheet(doc, id),
desktop: (id: string) => desktopView(doc, id),
insertText: (id: string, path: string, index: number, text: string) => doc.transact(() => fileText(doc, id, path)?.insert(index, text), 'other'),
writeFile: (id: string, path: string, text: string) => changeFiles(doc, id, 'other', (files) => files.writeTextFile(path, text)),
};
}, [doc]);