diff --git a/.gitignore b/.gitignore index 419e60d..20e8573 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,7 @@ next-env.d.ts skills-lock.json # Resume (copied from submodule at build time) -/public/resume \ No newline at end of file +/public/resume + +# Generated repo file-tree index (build time) +/public/repo-tree.json \ No newline at end of file diff --git a/docs/pixelfed-feed-consumption.md b/docs/pixelfed-feed-consumption.md deleted file mode 100644 index 80c868d..0000000 --- a/docs/pixelfed-feed-consumption.md +++ /dev/null @@ -1,521 +0,0 @@ -# Pixelfed Feed Consumption - Implementation Guide - -**Created:** 2026-06-10 -**Status:** Ready for Implementation -**Related:** `docs/pixelfed-integration.md`, `dpulse/docs/pixelfed-feed-integration.md` - ---- - -## Overview - -The site consumes Pixelfed feed data published by dpulse via Waku. This document explains how to receive, decode, and display feed entries. - -**Data Flow:** -1. dpulse fetches Atom feed → encodes as FeedBatch protobuf → publishes to Waku -2. Site subscribes via Waku Filter → receives FeedBatch → decodes → verifies signature -3. Site queries Waku Store for historical messages on startup -4. UI components reactively update via nanostores - ---- - -## Protobuf Schema - -### Types to Define - -Copy the FeedBatch schema from dpulse. Define in `src/lib/dpulse/protobuf/feed-schema.ts`: - -```typescript -import type { Type } from 'protobufjs'; -import protobuf from 'protobufjs'; - -export interface FeedImage { - url: string; - mimeType: string; -} - -export interface FeedEntry { - id: string; - title: string; - link: string; - content?: string; - author?: string; - published?: number; - images: FeedImage[]; -} - -export interface FeedBatch { - source: string; - fetchedAt: number; - entries: FeedEntry[]; - signature?: string; -} - -const FeedImageType = new protobuf.Type('FeedImage') - .add(new protobuf.Field('url', 1, 'string')) - .add(new protobuf.Field('mimeType', 2, 'string')); - -const FeedEntryType = new protobuf.Type('FeedEntry') - .add(new protobuf.Field('id', 1, 'string')) - .add(new protobuf.Field('title', 2, 'string')) - .add(new protobuf.Field('link', 3, 'string')) - .add(new protobuf.Field('content', 4, 'string', 'optional')) - .add(new protobuf.Field('author', 5, 'string', 'optional')) - .add(new protobuf.Field('published', 6, 'int64', 'optional')) - .add(new protobuf.Field('images', 7, 'FeedImage', 'repeated')); - -const FeedBatchType = new protobuf.Type('FeedBatch') - .add(new protobuf.Field('source', 1, 'string')) - .add(new protobuf.Field('fetchedAt', 2, 'int64')) - .add(new protobuf.Field('entries', 3, 'FeedEntry', 'repeated')) - .add(new protobuf.Field('signature', 4, 'string', 'optional')); - -const root = new protobuf.Root() - .define('dpulse') - .add(FeedImageType) - .add(FeedEntryType) - .add(FeedBatchType); - -export const FeedBatch = root.lookupType( - 'dpulse.FeedBatch', -) as unknown as Type; -``` - -**Field Numbers (from dpulse/src/protobuf/schema.ts):** -- `FeedImage`: `url=1`, `mimeType=2` -- `FeedEntry`: `id=1`, `title=2`, `link=3`, `content=4`, `author=5`, `published=6`, `images=7` -- `FeedBatch`: `source=1`, `fetchedAt=2`, `entries=3`, `signature=4` - ---- - -## Feed Codec - -Create `src/lib/dpulse/protobuf/feed-codec.ts`: - -```typescript -import { - base64ToSignature, - importPublicKey, - verifyMessage, -} from '../crypto/signature'; -import type { FeedBatch as FeedBatchType } from './feed-schema'; -import { FeedBatch } from './feed-schema'; - -export function decodeFeedBatch(bytes: Uint8Array): FeedBatchType { - const decoded = FeedBatch.decode(bytes); - return FeedBatch.toObject(decoded, { - longs: Number, - enums: Number, - bytes: Uint8Array, - }) as unknown as FeedBatchType; -} - -export function validateFeedBatch(batch: FeedBatchType): boolean { - if (!batch.source || typeof batch.source !== 'string') { - console.error('FeedBatch validation failed: missing or invalid source'); - return false; - } - - if (!batch.fetchedAt || typeof batch.fetchedAt !== 'number') { - console.error('FeedBatch validation failed: missing or invalid fetchedAt'); - return false; - } - - if (!Array.isArray(batch.entries)) { - console.error('FeedBatch validation failed: missing or invalid entries'); - return false; - } - - for (const entry of batch.entries) { - if (!entry.id || typeof entry.id !== 'string') { - console.error('FeedBatch validation failed: entry missing id'); - return false; - } - if (!entry.title || typeof entry.title !== 'string') { - console.error('FeedBatch validation failed: entry missing title'); - return false; - } - if (!entry.link || typeof entry.link !== 'string') { - console.error('FeedBatch validation failed: entry missing link'); - return false; - } - } - - if (!batch.signature || typeof batch.signature !== 'string') { - console.error('FeedBatch validation failed: missing signature'); - return false; - } - - return true; -} - -function serializeEntryForSigning(entry: FeedEntry): string { - const imagesStr = entry.images - .map((img) => `${img.url}:${img.mimeType}`) - .join(','); - return `${entry.id}:${entry.title}:${entry.link}:${entry.content || ''}:${entry.author || ''}:${entry.published?.toString() || ''}:${imagesStr}`; -} - -export async function validateAndDecodeFeedBatch( - bytes: Uint8Array, - publicKeyPem: string, -): Promise { - const batch = decodeFeedBatch(bytes); - - if (!validateFeedBatch(batch)) { - return null; - } - - try { - const publicKey = await importPublicKey(publicKeyPem); - - // Reconstruct the payload exactly as dpulse does - const entriesStr = batch.entries.map(serializeEntryForSigning).join('|'); - const payload = `${batch.source}:${entriesStr}:${batch.fetchedAt.toString()}`; - const payloadBytes = new TextEncoder().encode(payload); - - const signature = base64ToSignature(batch.signature as string); - - const isValid = await verifyMessage(payloadBytes, signature, publicKey); - - if (!isValid) { - console.error( - 'FeedBatch verification failed: invalid signature for', - batch.source, - ); - return null; - } - - return batch; - } catch (error) { - console.error('FeedBatch verification error:', error); - return null; - } -} -``` - ---- - -## Content Topic Configuration - -Add to `src/lib/dpulse/config.ts`: - -```typescript -export const DPULSE_CONFIG = { - contentTopic: '/dpulse_site/1.0.0/prod/proto', - feedContentTopic: '/pixelfed_feed/1.0.0/prod/proto', - publicKey: `-----BEGIN PUBLIC KEY----- -MCowBQYDK2VwAyEAt2CsQmm+c5L0UQmpkowqQ1WqTcFHsv6kZhI508t1sXU= ------END PUBLIC KEY-----`, - userAgent: 'dev.nipsys.site', -} as const; -``` - ---- - -## State Management - -Add to `src/lib/dpulse/stores.ts`: - -```typescript -import { atom } from 'nanostores'; -import type { ConnectionStatus, ServiceStatus } from './types'; -import type { FeedEntry } from './protobuf/feed-schema'; - -export const $connectionStatus = atom('disconnected'); -export const $statusMessages = atom>(new Map()); -export const $isLoading = atom(false); -export const $error = atom(null); -export const $peerCount = atom(0); - -// Feed stores -export const $feedEntries = atom([]); -export const $feedLastFetched = atom(null); -``` - ---- - -## Waku Integration - -### Option A: Extend Existing Clients - -Modify `waku-filter-client.ts` to support multiple content topics: - -```typescript -import type { IDecodedMessage, IDecoder, LightNode } from '@waku/sdk'; -import { DPULSE_CONFIG } from './constants'; -import { setError } from './manager'; -import { processMessagePayload } from './message-processor'; -import { processFeedBatch } from './feed-processor'; - -let statusDecoder: IDecoder | null = null; -let feedDecoder: IDecoder | null = null; - -export function getStatusDecoder(): IDecoder | null { - return statusDecoder; -} - -export function getFeedDecoder(): IDecoder | null { - return feedDecoder; -} - -export async function setupFilterSubscription(node: LightNode): Promise { - statusDecoder = node.createDecoder({ contentTopic: DPULSE_CONFIG.contentTopic }); - feedDecoder = node.createDecoder({ contentTopic: DPULSE_CONFIG.feedContentTopic }); - - console.log('[dpulse] Subscribing to status topic:', DPULSE_CONFIG.contentTopic); - console.log('[dpulse] Subscribing to feed topic:', DPULSE_CONFIG.feedContentTopic); - - await node.filter.subscribe([statusDecoder], handleStatusMessage); - await node.filter.subscribe([feedDecoder], handleFeedMessage); - - console.log('[dpulse] Successfully subscribed to all topics'); -} - -async function handleStatusMessage(wakuMessage: IDecodedMessage): Promise { - try { - const payload = wakuMessage.payload; - if (!payload || payload.length === 0) return; - await processMessagePayload(payload, 'filter'); - } catch (error) { - console.error('[dpulse] Error handling status message:', error); - setError(error instanceof Error ? error.message : 'Unknown error'); - } -} - -async function handleFeedMessage(wakuMessage: IDecodedMessage): Promise { - try { - const payload = wakuMessage.payload; - if (!payload || payload.length === 0) return; - await processFeedBatch(payload, 'filter'); - } catch (error) { - console.error('[dpulse] Error handling feed message:', error); - } -} -``` - -### Option B: Create Feed-Specific Client - -Create `src/lib/dpulse/feed-client.ts`: - -```typescript -import type { IDecodedMessage, IDecoder, LightNode } from '@waku/sdk'; -import { DPULSE_CONFIG, STORE_HISTORY_HOURS } from './constants'; -import { validateAndDecodeFeedBatch } from './protobuf/feed-codec'; -import { $feedEntries, $feedLastFetched } from './stores'; - -let feedDecoder: IDecoder | null = null; - -export function getFeedDecoder(): IDecoder | null { - return feedDecoder; -} - -export async function setupFeedSubscription(node: LightNode): Promise { - feedDecoder = node.createDecoder({ contentTopic: DPULSE_CONFIG.feedContentTopic }); - - console.log('[dpulse] Subscribing to feed topic:', DPULSE_CONFIG.feedContentTopic); - await node.filter.subscribe([feedDecoder], handleFeedMessage); - console.log('[dpulse] Successfully subscribed to feed messages'); -} - -async function handleFeedMessage(wakuMessage: IDecodedMessage): Promise { - try { - const payload = wakuMessage.payload; - if (!payload || payload.length === 0) return; - await processFeedBatch(payload, 'filter'); - } catch (error) { - console.error('[dpulse] Error handling feed message:', error); - } -} - -export async function processFeedBatch( - payload: Uint8Array, - source: 'filter' | 'store', -): Promise { - const batch = await validateAndDecodeFeedBatch( - payload, - DPULSE_CONFIG.publicKey, - ); - - if (!batch) return false; - - const currentEntries = $feedEntries.get(); - const existingIds = new Set(currentEntries.map((e) => e.id)); - - const newEntries = batch.entries.filter((e) => !existingIds.has(e.id)); - - if (newEntries.length > 0) { - $feedEntries.set([...newEntries, ...currentEntries].slice(0, 100)); - } - - const lastFetched = $feedLastFetched.get(); - if (!lastFetched || batch.fetchedAt > lastFetched) { - $feedLastFetched.set(batch.fetchedAt); - } - - console.log( - `[dpulse] Processed FeedBatch from ${source}: ${batch.entries.length} entries, ${newEntries.length} new`, - ); - return true; -} - -export async function queryFeedHistory(node: LightNode): Promise { - const storeDecoder = node.createDecoder({ - contentTopic: DPULSE_CONFIG.feedContentTopic, - }); - - const now = new Date(); - const startTime = new Date(now.getTime() - STORE_HISTORY_HOURS * 3600000); - - console.log( - `[dpulse] Querying Store for feed messages from ${startTime.toISOString()} to ${now.toISOString()}`, - ); - - const decodePromises: Promise[] = []; - - try { - await node.store.queryWithOrderedCallback( - [storeDecoder], - (wakuMessage) => { - const payload = wakuMessage.payload; - if (!payload || payload.length === 0) return false; - - if (wakuMessage.timestamp) { - const msgTime = wakuMessage.timestamp; - if (msgTime < startTime || msgTime > now) return false; - } - - decodePromises.push(processFeedBatch(payload, 'store')); - return false; - }, - { - paginationForward: false, - timeStart: startTime, - timeEnd: now, - }, - ); - - const results = await Promise.all(decodePromises); - const messageCount = results.filter(Boolean).length; - - console.log(`[dpulse] Feed Store query complete, processed ${messageCount} batches`); - return messageCount; - } catch (error) { - console.error('[dpulse] Feed Store query failed:', error); - return 0; - } -} -``` - ---- - -## Component Updates - -### Update PixelFedGallery.tsx - -Replace ISR fetch with store subscription: - -```typescript -'use client'; - -import { Badge, Card, CardContent, Typography } from '@nipsys/lsd'; -import { ArrowSquareOutIcon } from '@phosphor-icons/react'; -import Image from 'next/image'; -import { useStore } from '@nanostores/react'; -import { $feedEntries, $feedLastFetched, $isLoading, $error } from '@/lib/dpulse/stores'; - -export default function PixelFedGallery() { - const entries = useStore($feedEntries); - const lastFetched = useStore($feedLastFetched); - const isLoading = useStore($isLoading); - const error = useStore($error); - - if (isLoading && entries.length === 0) { - return ( - - Loading gallery... - - ); - } - - if (error && entries.length === 0) { - return ( - - {error} - - ); - } - - if (entries.length === 0) { - return ( - - No posts found. - - ); - } - - return ( -
- {entries.map((entry) => ( - - - {entry.images.length > 0 && ( - {entry.title} - )} -
- - {entry.title || entry.content || 'Untitled'} - -
- - {entry.published - ? new Date(entry.published).toLocaleDateString() - : 'Unknown date'} - - - - -
-
-
-
- ))} -
- ); -} -``` - ---- - -## Implementation Checklist - -- [ ] Create `src/lib/dpulse/protobuf/feed-schema.ts` -- [ ] Create `src/lib/dpulse/protobuf/feed-codec.ts` -- [ ] Add `feedContentTopic` to `config.ts` -- [ ] Add feed stores to `stores.ts` -- [ ] Create `feed-client.ts` or extend existing Waku clients -- [ ] Initialize feed subscription in `manager.ts` -- [ ] Update `PixelFedGallery.tsx` to use stores -- [ ] Remove or keep old ISR fetch from `src/lib/pixelfed.ts` as fallback - ---- - -## Notes - -- **Signature verification**: Uses the same Ed25519 public key as status messages (defined in `DPULSE_CONFIG.publicKey`) -- **Batch limits**: Feed batches are limited to 20 entries, published every 30 minutes -- **Deduplication**: Handle duplicate entries (same `id`) by filtering in `processFeedBatch` -- **Store limit**: Consider capping stored entries (e.g., 100) to prevent memory issues -- **Timestamp display**: Use `$feedLastFetched` to show "last updated" in UI -- **Fallback**: Keep existing ISR fetch in `src/lib/pixelfed.ts` as a fallback if Waku is unavailable diff --git a/package.json b/package.json index 1a85373..3815e8d 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,11 @@ }, "scripts": { "copy-resume": "node scripts/copy-resume.js", - "dev": "pnpm copy-resume && next dev --turbopack", - "dev-legacy": "pnpm copy-resume && next dev", - "build": "pnpm copy-resume && next build", - "export": "pnpm copy-resume && next build", + "gen-index": "node scripts/generate-file-index.js", + "dev": "pnpm copy-resume && pnpm gen-index && next dev --turbopack", + "dev-legacy": "pnpm copy-resume && pnpm gen-index && next dev", + "build": "pnpm copy-resume && pnpm gen-index && next build", + "export": "pnpm copy-resume && pnpm gen-index && next build", "analyze": "ANALYZE=true next build", "serve": "npx serve@latest out", "test:unit": "vitest run", diff --git a/scripts/generate-file-index.js b/scripts/generate-file-index.js new file mode 100644 index 0000000..f582ff2 --- /dev/null +++ b/scripts/generate-file-index.js @@ -0,0 +1,75 @@ +const { execSync } = require('node:child_process'); +const fs = require('node:fs'); + +const REPO_OWNER = 'nipsysdev'; +const REPO_NAME = 'site'; +const OUT_FILE = 'public/repo-tree.json'; +const EXCLUDE_PREFIXES = ['external/']; // submodule — separate repo + +function getCommit() { + try { + return execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim(); + } catch { + return 'unknown'; + } +} + +function listFiles() { + // git ls-tree -r -l HEAD outputs: " \t" + // type is 'blob' for files; size is the byte size or '-' for non-blobs. + try { + const out = execSync('git ls-tree -r -l HEAD', { encoding: 'utf-8' }); + const files = []; + for (const line of out.split('\n')) { + if (!line.trim()) continue; + const tabIdx = line.indexOf('\t'); + if (tabIdx === -1) continue; + const meta = line.slice(0, tabIdx).trim(); + const filePath = line.slice(tabIdx + 1); + if (!filePath) continue; + if (EXCLUDE_PREFIXES.some((p) => filePath.startsWith(p))) continue; + const [, type, , sizeStr] = meta.split(/\s+/); + if (type !== 'blob') continue; // skip submodule 'commit' entries etc. + const size = sizeStr && sizeStr !== '-' ? Number(sizeStr) : undefined; + files.push({ path: filePath, type: 'blob', size }); + } + return files; + } catch (e) { + console.error( + 'generate-file-index: failed to list files via git:', + e.message, + ); + return []; + } +} + +function deriveDirs(files) { + const dirs = new Set(); + for (const f of files) { + const parts = f.path.split('/'); + for (let i = 1; i < parts.length; i++) { + dirs.add(parts.slice(0, i).join('/')); + } + } + return [...dirs].map((d) => ({ path: d, type: 'tree' })); +} + +const commit = getCommit(); +const files = listFiles(); +const dirs = deriveDirs(files); +const entries = [...files, ...dirs].sort((a, b) => + a.path.localeCompare(b.path), +); + +fs.mkdirSync('public', { recursive: true }); +fs.writeFileSync( + OUT_FILE, + JSON.stringify( + { owner: REPO_OWNER, name: REPO_NAME, commit, entries }, + null, + 2, + ), +); +console.log( + `Generated ${OUT_FILE}: ${files.length} files, ${dirs.length} dirs (commit ${commit})`, +); diff --git a/src/__tests__/components/CmdLink.test.tsx b/src/__tests__/components/CmdLink.test.tsx index 5536025..b18c93d 100644 --- a/src/__tests__/components/CmdLink.test.tsx +++ b/src/__tests__/components/CmdLink.test.tsx @@ -50,9 +50,30 @@ describe('CmdLink', () => { }; render(); expect(screen.getByText('help')).toBeInTheDocument(); - // Check for the options text in the button + // Each flag is bracketed to read as a separate, optional modifier. const button = screen.getByRole('button'); - expect(button.textContent).toContain('--all|--short'); + expect(button.textContent).toContain('[--all]'); + expect(button.textContent).toContain('[--short]'); + }); + + it('renders flags and usage together when both are set', () => { + const cmdInfo: CommandInfo = { + name: Command.Ls, + options: ['-l', '-a'], + usage: '[path]', + }; + render(); + const button = screen.getByRole('button'); + expect(button.textContent).toContain('[-l]'); + expect(button.textContent).toContain('[-a]'); + expect(button.textContent).toContain('[path]'); + }); + + it('renders the usage hint when cmdInfo has usage', () => { + const cmdInfo: CommandInfo = { name: Command.Ls, usage: '[path]' }; + render(); + expect(screen.getByText('ls')).toBeInTheDocument(); + expect(screen.getByRole('button').textContent).toContain('[path]'); }); it('renders with argument options when arg has options', () => { @@ -108,6 +129,14 @@ describe('CmdLink', () => { expect(mockSimulateInput).not.toHaveBeenCalled(); }); + it('sets input with space when command has usage', () => { + const cmdInfo: CommandInfo = { name: Command.Cat, usage: '' }; + render(); + fireEvent.click(screen.getByRole('button')); + expect(mockSetInput).toHaveBeenCalledWith('cat '); + expect(mockSimulateInput).not.toHaveBeenCalled(); + }); + it('sets input with argument pattern when arg is provided', () => { const cmdInfo: CommandInfo = { name: Command.Help, diff --git a/src/__tests__/components/TerminalEmulator.test.tsx b/src/__tests__/components/TerminalEmulator.test.tsx index 3b10606..e94e1ff 100644 --- a/src/__tests__/components/TerminalEmulator.test.tsx +++ b/src/__tests__/components/TerminalEmulator.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildCommandEntry } from '@/__tests__/fixtures/terminal-fixtures'; import TerminalEmulator from '@/components/terminal/TerminalEmulator'; import { $isAppReady } from '@/stores/app-store'; import { @@ -33,6 +34,12 @@ vi.mock('@/stores/app-store', () => ({ }, })); +vi.mock('@/stores/repo-store', () => ({ + $repoTree: { + listen: vi.fn(() => () => {}), + }, +})); + vi.mock('next-intl', () => ({ useTranslations: vi.fn(() => (key: string) => key), })); @@ -91,8 +98,8 @@ describe('TerminalEmulator', () => { it('renders history entries', () => { const entries: CommandEntry[] = [ - { timestamp: 1000, cmdName: Command.Help }, - { timestamp: 2000, cmdName: Command.Whoami }, + buildCommandEntry({ cmdName: Command.Help, timestamp: 1000 }), + buildCommandEntry({ cmdName: Command.Whoami, timestamp: 2000 }), ]; mockHistoryGet.mockReturnValue(entries); @@ -103,9 +110,9 @@ describe('TerminalEmulator', () => { it('respects historyVisibleIdx to slice history', () => { const entries: CommandEntry[] = [ - { timestamp: 1000, cmdName: Command.Help }, - { timestamp: 2000, cmdName: Command.Whoami }, - { timestamp: 3000, cmdName: Command.Contact }, + buildCommandEntry({ cmdName: Command.Help, timestamp: 1000 }), + buildCommandEntry({ cmdName: Command.Whoami, timestamp: 2000 }), + buildCommandEntry({ cmdName: Command.Contact, timestamp: 3000 }), ]; mockHistoryGet.mockReturnValue(entries); mockHistoryVisibleIdxGet.mockReturnValue(1); @@ -122,11 +129,11 @@ describe('TerminalEmulator', () => {
Output for {entry.cmdName}
)); const entries: CommandEntry[] = [ - { - timestamp: 1000, + buildCommandEntry({ cmdName: Command.Help, output: MockOutput, - }, + timestamp: 1000, + }), ]; mockHistoryGet.mockReturnValue(entries); @@ -134,9 +141,13 @@ describe('TerminalEmulator', () => { expect(screen.getByTestId('mock-output')).toBeInTheDocument(); }); - it('renders UnknownCmdOutput when entry has cmdName but no output', () => { + it('renders UnknownCmdOutput when entry has an unrecognized cmdName and no output', () => { const entries: CommandEntry[] = [ - { timestamp: 1000, cmdName: Command.Help }, + buildCommandEntry({ + cmdName: 'foobar' as Command, + output: undefined, + timestamp: 1000, + }), ]; mockHistoryGet.mockReturnValue(entries); @@ -144,9 +155,48 @@ describe('TerminalEmulator', () => { expect(screen.getByTestId('unknown-cmd-output')).toBeInTheDocument(); }); + it('renders the entry error text and no UnknownCmdOutput when entry has an error', () => { + const entries: CommandEntry[] = [ + buildCommandEntry({ + cmdName: Command.Clear, + output: undefined, + error: 'boom', + timestamp: 1000, + }), + ]; + mockHistoryGet.mockReturnValue(entries); + + render(); + expect(screen.getByText('boom')).toBeInTheDocument(); + expect( + screen.queryByTestId('unknown-cmd-output'), + ).not.toBeInTheDocument(); + }); + + it('renders nothing for a recognized command with no output and no error', () => { + const entries: CommandEntry[] = [ + buildCommandEntry({ + cmdName: Command.Clear, + output: undefined, + timestamp: 1000, + }), + ]; + mockHistoryGet.mockReturnValue(entries); + + render(); + expect( + screen.queryByTestId('unknown-cmd-output'), + ).not.toBeInTheDocument(); + }); + it('does not render output when entry has no cmdName', () => { const entries: CommandEntry[] = [ - { timestamp: 1000, cmdName: undefined as unknown as Command }, + { + timestamp: 1000, + cmdName: undefined as unknown as Command, + args: { positional: [], flags: [], options: {} }, + rawInput: '', + }, ]; mockHistoryGet.mockReturnValue(entries); @@ -184,6 +234,25 @@ describe('TerminalEmulator', () => { expect(container).toBeInTheDocument(); expect(container).toHaveAttribute('tabIndex', '0'); }); + + it('does not swallow Space typed in the prompt input (regression)', () => { + render(); + // The stubbed main prompt renders an input with defaultValue 'test-input'. + const input = screen.getByDisplayValue('test-input'); + + // Dispatch a real keydown so it bubbles to the real TerminalEmulator + // container handler. Without the `target === currentTarget` guard, the + // container's preventDefault() deletes the Space character, making it + // impossible to type args like `cat README.md`. + const event = new KeyboardEvent('keydown', { + key: ' ', + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + }); }); describe('store integration', () => { @@ -198,7 +267,7 @@ describe('TerminalEmulator', () => { it('uses history from store', () => { const entries: CommandEntry[] = [ - { timestamp: 1000, cmdName: Command.Help }, + buildCommandEntry({ cmdName: Command.Help, timestamp: 1000 }), ]; mockHistoryGet.mockReturnValue(entries); diff --git a/src/__tests__/components/TerminalPrompt.test.tsx b/src/__tests__/components/TerminalPrompt.test.tsx index 4c98ae3..30333ad 100644 --- a/src/__tests__/components/TerminalPrompt.test.tsx +++ b/src/__tests__/components/TerminalPrompt.test.tsx @@ -37,6 +37,7 @@ vi.mock('@/utils/terminal-utils', () => ({ })); import type { Translator } from '@/i18n/intl'; +import { $cwd } from '@/stores/repo-store'; import { $terminalInput, $terminalInputReadOnly, @@ -64,16 +65,18 @@ describe('TerminalPrompt', () => { mockInputGet.mockReturnValue(''); mockReadOnlyGet.mockReturnValue(false); mockSuggestionsGet.mockReturnValue(null); + $cwd.set('/'); }); afterEach(() => { vi.resetAllMocks(); + $cwd.set('/'); }); describe('rendering', () => { it('renders the prompt with visitor and host', () => { render(); - expect(screen.getByText(/visitor@localhost:~\$/)).toBeInTheDocument(); + expect(screen.getByText(/visitor@localhost:\/\$/)).toBeInTheDocument(); }); it('renders an input field', () => { @@ -92,6 +95,8 @@ describe('TerminalPrompt', () => { const entry: CommandEntry = { timestamp: Date.now(), cmdName: Command.Help, + args: { positional: [], flags: [], options: {} }, + rawInput: 'help', }; render(); const input = screen.getByRole('textbox'); @@ -102,6 +107,8 @@ describe('TerminalPrompt', () => { const entry: CommandEntry = { timestamp: Date.now(), cmdName: Command.Help, + args: { positional: [], flags: [], options: {} }, + rawInput: 'help', }; render(); const input = screen.getByRole('textbox'); @@ -116,6 +123,29 @@ describe('TerminalPrompt', () => { }); }); + describe('prompt path from cwd', () => { + it('renders the live cwd in the prompt', () => { + $cwd.set('/src'); + render(); + expect(screen.getByText(/visitor@localhost:\/src\$/)).toBeInTheDocument(); + }); + + it('uses the entry snapshot cwd when an entry is provided', () => { + const entry: CommandEntry = { + timestamp: Date.now(), + cmdName: Command.Help, + args: { positional: [], flags: [], options: {} }, + rawInput: 'help', + cwd: '/src/app', + }; + $cwd.set('/'); + render(); + expect( + screen.getByText(/visitor@localhost:\/src\/app\$/), + ).toBeInTheDocument(); + }); + }); + describe('suggestions', () => { it('does not show suggestions when null', () => { mockSuggestionsGet.mockReturnValue(null); @@ -142,6 +172,8 @@ describe('TerminalPrompt', () => { const entry: CommandEntry = { timestamp: Date.now(), cmdName: Command.Help, + args: { positional: [], flags: [], options: {} }, + rawInput: 'help', }; render(); expect(screen.queryByText('help')).not.toBeInTheDocument(); diff --git a/src/__tests__/components/cmd-outputs/CatOutput.test.tsx b/src/__tests__/components/cmd-outputs/CatOutput.test.tsx new file mode 100644 index 0000000..674cc9b --- /dev/null +++ b/src/__tests__/components/cmd-outputs/CatOutput.test.tsx @@ -0,0 +1,175 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import CatOutput from '@/components/cmd-outputs/CatOutput'; +import { type ReadResult, readRepoFile } from '@/lib/repo/fetch'; +import type { RepoEntry } from '@/lib/repo/types'; +import { createVfs } from '@/lib/repo/vfs'; +import { $repoTree } from '@/stores/repo-store'; +import { Command, type CommandEntry } from '@/types/terminal'; + +vi.mock('@/lib/repo/fetch', () => ({ + readRepoFile: vi.fn(), + buildRawUrl: vi.fn( + (absPath: string) => `https://raw.example${absPath}` as string, + ), +})); + +vi.mock('next-intl', () => ({ + useTranslations: vi.fn(() => (key: string) => key), +})); + +const SAMPLE_ENTRIES: RepoEntry[] = [ + { path: 'README.md', type: 'blob', size: 100 }, + { path: '.gitignore', type: 'blob', size: 20 }, + { path: 'src/index.ts', type: 'blob', size: 5 }, + { path: 'src/app/page.tsx', type: 'blob', size: 7 }, + { path: 'package.json', type: 'blob', size: 50 }, +]; + +const mockedReadRepoFile = vi.mocked(readRepoFile); + +function makeEntry(overrides: Partial = {}): CommandEntry { + return { + timestamp: Date.now(), + cmdName: Command.Cat, + args: { positional: [], flags: [], options: {} }, + rawInput: 'cat', + ...overrides, + }; +} + +function seedRepoTree(entries: RepoEntry[] = SAMPLE_ENTRIES) { + $repoTree.set({ + vfs: createVfs(entries), + commit: 'abc', + loading: false, + error: null, + }); +} + +describe('CatOutput', () => { + beforeEach(() => { + seedRepoTree(); + mockedReadRepoFile.mockReset(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => ({ + owner: 'nipsysdev', + name: 'site', + commit: 'abc', + entries: SAMPLE_ENTRIES, + }), + })), + ); + }); + + afterEach(() => { + cleanup(); + $repoTree.set({ vfs: null, commit: null, loading: true, error: null }); + vi.unstubAllGlobals(); + }); + + it('renders a missing-operand error without fetching', () => { + render(); + + expect(screen.getByText('cat: missing operand')).toBeInTheDocument(); + expect(mockedReadRepoFile).not.toHaveBeenCalled(); + }); + + it('shows a loading message (not "…") while fetching a file', async () => { + let resolveFetch!: (value: ReadResult) => void; + mockedReadRepoFile.mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + render( + , + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + expect(screen.queryByText('...')).not.toBeInTheDocument(); + + resolveFetch({ ok: true, content: 'done', size: 4 }); + expect(await screen.findByText('done')).toBeInTheDocument(); + }); + + it('renders the file content for a text file', async () => { + mockedReadRepoFile.mockResolvedValue({ + ok: true, + content: 'hello world', + size: 11, + }); + + render( + , + ); + + expect(await screen.findByText('hello world')).toBeInTheDocument(); + }); + + it('renders an error for a directory target without fetching', () => { + render( + , + ); + + expect(screen.getByText('cat: src: Is a directory')).toBeInTheDocument(); + expect(mockedReadRepoFile).not.toHaveBeenCalled(); + }); + + it('renders a not-found error for a missing target', () => { + render( + , + ); + + expect( + screen.getByText('cat: nope: No such file or directory'), + ).toBeInTheDocument(); + expect(mockedReadRepoFile).not.toHaveBeenCalled(); + }); + + it('renders a binary notice with a raw link for binary files', async () => { + mockedReadRepoFile.mockResolvedValue({ + ok: true, + binary: true, + size: 4096, + }); + + render( + , + ); + + expect(await screen.findByText(/4096 bytes/)).toBeInTheDocument(); + const link = await screen.findByRole('link', { name: /view raw/ }); + expect(link).toHaveAttribute('href', 'https://raw.example/README.md'); + expect(mockedReadRepoFile).toHaveBeenCalledWith('/README.md'); + }); +}); diff --git a/src/__tests__/components/cmd-outputs/LsOutput.test.tsx b/src/__tests__/components/cmd-outputs/LsOutput.test.tsx new file mode 100644 index 0000000..4e40bb4 --- /dev/null +++ b/src/__tests__/components/cmd-outputs/LsOutput.test.tsx @@ -0,0 +1,158 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LsOutput from '@/components/cmd-outputs/LsOutput'; +import type { RepoEntry } from '@/lib/repo/types'; +import { createVfs } from '@/lib/repo/vfs'; +import { $repoTree } from '@/stores/repo-store'; +import { Command, type CommandEntry } from '@/types/terminal'; + +vi.mock('next-intl', () => ({ + useTranslations: vi.fn(() => (key: string) => key), +})); + +const SAMPLE_ENTRIES: RepoEntry[] = [ + { path: 'README.md', type: 'blob', size: 100 }, + { path: '.gitignore', type: 'blob', size: 20 }, + { path: 'src/index.ts', type: 'blob', size: 5 }, + { path: 'src/app/page.tsx', type: 'blob', size: 7 }, + { path: 'package.json', type: 'blob', size: 50 }, +]; + +function makeEntry(overrides: Partial = {}): CommandEntry { + return { + timestamp: Date.now(), + cmdName: Command.Ls, + args: { positional: [], flags: [], options: {} }, + rawInput: 'ls', + ...overrides, + }; +} + +function seedRepoTree(entries: RepoEntry[] = SAMPLE_ENTRIES) { + $repoTree.set({ + vfs: createVfs(entries), + commit: 'abc', + loading: false, + error: null, + }); +} + +describe('LsOutput', () => { + beforeEach(() => { + seedRepoTree(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => ({ + owner: 'nipsysdev', + name: 'site', + commit: 'abc', + entries: SAMPLE_ENTRIES, + }), + })), + ); + }); + + afterEach(() => { + cleanup(); + $repoTree.set({ vfs: null, commit: null, loading: true, error: null }); + vi.unstubAllGlobals(); + }); + + it('lists top-level entries and hides dotfiles by default', () => { + render(); + + expect(screen.getByText('README.md')).toBeInTheDocument(); + expect(screen.getByText('package.json')).toBeInTheDocument(); + expect(screen.getByText('src')).toBeInTheDocument(); + expect(screen.queryByText('.gitignore')).not.toBeInTheDocument(); + }); + + it('includes dotfiles with the -a flag', () => { + render( + , + ); + + expect(screen.getByText('.gitignore')).toBeInTheDocument(); + expect(screen.getByText('README.md')).toBeInTheDocument(); + }); + + it('lists the contents of a subdirectory', () => { + render( + , + ); + + expect(screen.getByText('index.ts')).toBeInTheDocument(); + expect(screen.getByText('app')).toBeInTheDocument(); + }); + + it('renders long format with sizes, one row per entry', () => { + const { container } = render( + , + ); + + expect(screen.getByText('100')).toBeInTheDocument(); + expect(screen.getByText('README.md')).toBeInTheDocument(); + // 3 visible top-level entries (README.md, package.json, src) => 3 rows + expect(container.firstElementChild?.children.length).toBe(3); + }); + + it('renders an error for a nonexistent target', () => { + render( + , + ); + + expect( + screen.getByText(/cannot access 'nope': No such file or directory/), + ).toBeInTheDocument(); + }); + + it('renders just the name when the target is a file', () => { + const { container } = render( + , + ); + + expect(screen.getByText('README.md')).toBeInTheDocument(); + // A single element (the name), not a directory listing. + expect(container.firstElementChild?.children.length).toBe(0); + }); + + it('renders a loading indicator while the vfs loads', () => { + $repoTree.set({ vfs: null, commit: null, loading: true, error: null }); + render(); + expect(screen.getByText('loading')).toBeInTheDocument(); + expect(screen.queryByText('...')).not.toBeInTheDocument(); + }); + + it('renders a destructive message when the filesystem is unavailable', () => { + $repoTree.set({ vfs: null, commit: null, loading: false, error: 'boom' }); + render(); + expect(screen.getByText('ls: filesystem unavailable')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/cmd-outputs/PwdOutput.test.tsx b/src/__tests__/components/cmd-outputs/PwdOutput.test.tsx new file mode 100644 index 0000000..737edf9 --- /dev/null +++ b/src/__tests__/components/cmd-outputs/PwdOutput.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import PwdOutput from '@/components/cmd-outputs/PwdOutput'; +import { Command, type CommandEntry } from '@/types/terminal'; + +function makeEntry(overrides: Partial = {}): CommandEntry { + return { + timestamp: Date.now(), + cmdName: Command.Pwd, + args: { positional: [], flags: [], options: {} }, + rawInput: 'pwd', + ...overrides, + }; +} + +describe('PwdOutput', () => { + it('prints the entry cwd when set', () => { + render(); + expect(screen.getByText('/src')).toBeInTheDocument(); + }); + + it('defaults to root when cwd is missing', () => { + render(); + expect(screen.getByText('/')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/fixtures/terminal-fixtures.ts b/src/__tests__/fixtures/terminal-fixtures.ts index edbce6c..b4092fb 100644 --- a/src/__tests__/fixtures/terminal-fixtures.ts +++ b/src/__tests__/fixtures/terminal-fixtures.ts @@ -1,13 +1,22 @@ import HelpOutput from '@/components/cmd-outputs/HelpOutput'; -import { Command, type CommandEntry } from '@/types/terminal'; +import { + Command, + type CommandEntry, + type ParsedArguments, +} from '@/types/terminal'; + +const EMPTY_ARGS: ParsedArguments = { positional: [], flags: [], options: {} }; export function buildCommandEntry( overrides: Partial = {}, ): CommandEntry { + const { cmdName = Command.Help, ...rest } = overrides; return { timestamp: Date.now(), - cmdName: Command.Help, + cmdName, output: HelpOutput, - ...overrides, + args: EMPTY_ARGS, + rawInput: cmdName as string, + ...rest, }; } diff --git a/src/__tests__/lib/repo/complete.test.ts b/src/__tests__/lib/repo/complete.test.ts new file mode 100644 index 0000000..7216d65 --- /dev/null +++ b/src/__tests__/lib/repo/complete.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { completePath } from '@/lib/repo/complete'; +import type { RepoEntry } from '@/lib/repo/types'; +import { createVfs } from '@/lib/repo/vfs'; + +const entries: RepoEntry[] = [ + { path: 'README.md', type: 'blob', size: 100 }, + { path: '.gitignore', type: 'blob', size: 10 }, + { path: 'package.json', type: 'blob', size: 50 }, + { path: 'src', type: 'tree' }, + { path: 'src/index.ts', type: 'blob', size: 20 }, + { path: 'src/app', type: 'tree' }, + { path: 'src/app/page.tsx', type: 'blob', size: 30 }, + { path: 'src/lib', type: 'tree' }, +]; +const vfs = createVfs(entries); + +describe('completePath', () => { + describe('single match', () => { + it('completes a unique file at root', () => { + const result = completePath(vfs, '/', 'READ'); + expect(result.completed).toBe('README.md'); + expect(result.suggestions).toEqual(['README.md']); + }); + + it('completes a unique directory with a trailing slash', () => { + const result = completePath(vfs, '/', 'sr'); + expect(result.completed).toBe('src/'); + expect(result.suggestions).toEqual(['src/']); + }); + + it('preserves the user-typed directory part', () => { + const result = completePath(vfs, '/', 'src/in'); + expect(result.completed).toBe('src/index.ts'); + }); + + it('completes inside a subdirectory relative to cwd', () => { + const result = completePath(vfs, '/src', 'ap'); + expect(result.completed).toBe('app/'); + }); + + it('resolves a relative directory part (..)', () => { + const result = completePath(vfs, '/src/app', '../li'); + expect(result.completed).toBe('../lib/'); + }); + + it('expands ~ to root', () => { + const result = completePath(vfs, '/src', '~/sr'); + expect(result.completed).toBe('~/src/'); + }); + + it('includes dotfiles in completion', () => { + const result = completePath(vfs, '/', '.git'); + expect(result.completed).toBe('.gitignore'); + }); + }); + + describe('multiple matches', () => { + it('returns candidate names without completed', () => { + const result = completePath(vfs, '/', 'src/'); + expect(result.completed).toBeUndefined(); + expect(result.suggestions).toEqual( + expect.arrayContaining(['app', 'index.ts', 'lib']), + ); + }); + + it('lists all children when prefix is empty', () => { + const result = completePath(vfs, '/src', ''); + expect(result.completed).toBeUndefined(); + expect(result.suggestions.length).toBe(3); + }); + }); + + describe('no matches', () => { + it('returns an empty suggestion list for an unknown prefix', () => { + const result = completePath(vfs, '/', 'zzz'); + expect(result.completed).toBeUndefined(); + expect(result.suggestions).toEqual([]); + }); + + it('returns empty when the directory part is not a directory', () => { + const result = completePath(vfs, '/', 'README.md/foo'); + expect(result.suggestions).toEqual([]); + }); + }); + + describe('kind = dir (cd)', () => { + it('completes a directory', () => { + const result = completePath(vfs, '/src', '', 'dir'); + expect(result.suggestions).toEqual( + expect.arrayContaining(['app', 'lib']), + ); + expect(result.suggestions).not.toContain('index.ts'); + }); + + it('excludes files entirely', () => { + const result = completePath(vfs, '/src', 'index', 'dir'); + expect(result.suggestions).toEqual([]); + }); + + it('completes a single directory with trailing slash', () => { + const result = completePath(vfs, '/', 'sr', 'dir'); + expect(result.completed).toBe('src/'); + }); + }); +}); diff --git a/src/__tests__/lib/repo/fetch.test.ts b/src/__tests__/lib/repo/fetch.test.ts new file mode 100644 index 0000000..bd49233 --- /dev/null +++ b/src/__tests__/lib/repo/fetch.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildRawUrl, detectBinary } from '@/lib/repo/fetch'; + +describe('buildRawUrl', () => { + const originalCommit = process.env.BUILD_COMMIT; + + afterEach(() => { + if (originalCommit === undefined) { + delete process.env.BUILD_COMMIT; + } else { + process.env.BUILD_COMMIT = originalCommit; + } + }); + + it('uses an explicit ref', () => { + expect(buildRawUrl('/src/app/page.tsx', 'main')).toBe( + 'https://raw.githubusercontent.com/nipsysdev/site/main/src/app/page.tsx', + ); + }); + + it('falls back to BUILD_COMMIT when ref is omitted', () => { + process.env.BUILD_COMMIT = 'abc1234'; + expect(buildRawUrl('/src/app/page.tsx')).toBe( + 'https://raw.githubusercontent.com/nipsysdev/site/abc1234/src/app/page.tsx', + ); + }); + + it('falls back to main when neither ref nor BUILD_COMMIT is set', () => { + delete process.env.BUILD_COMMIT; + expect(buildRawUrl('/README.md')).toBe( + 'https://raw.githubusercontent.com/nipsysdev/site/main/README.md', + ); + }); + + it('handles root path', () => { + expect(buildRawUrl('/', 'main')).toBe( + 'https://raw.githubusercontent.com/nipsysdev/site/main/', + ); + }); +}); + +describe('detectBinary', () => { + it('returns false for plain text', () => { + expect(detectBinary(new Uint8Array([72, 101, 108, 108, 111]))).toBe(false); + }); + + it('returns true when a NUL byte is present', () => { + expect(detectBinary(new Uint8Array([0, 72, 101]))).toBe(true); + }); + + it('returns true for a NUL within the 8000-byte window', () => { + const bytes = new Uint8Array(8001).fill(1); + bytes[7999] = 0; + expect(detectBinary(bytes)).toBe(true); + }); + + it('returns false for a NUL beyond the 8000-byte window', () => { + const bytes = new Uint8Array(8001).fill(1); + bytes[8000] = 0; + expect(detectBinary(bytes)).toBe(false); + }); + + it('returns false for an empty array', () => { + expect(detectBinary(new Uint8Array(0))).toBe(false); + }); +}); + +describe('readRepoFile', () => { + beforeEach(() => { + // Reset module registry so the module-level cache is fresh per test. + vi.resetModules(); + delete process.env.BUILD_COMMIT; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns decoded text for a text file', async () => { + const text = 'export const x = 1;'; + const bytes = new TextEncoder().encode(text); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + arrayBuffer: async () => bytes.buffer, + }), + ); + const { readRepoFile } = await import('@/lib/repo/fetch'); + + const result = await readRepoFile('/src/index.ts'); + + expect(result).toEqual({ ok: true, content: text, size: bytes.byteLength }); + }); + + it('returns binary:true without content for a binary file', async () => { + const bytes = new Uint8Array([72, 0, 108]); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + arrayBuffer: async () => bytes.buffer, + }), + ); + const { readRepoFile } = await import('@/lib/repo/fetch'); + + const result = await readRepoFile('/img/logo.png'); + + expect(result).toEqual({ ok: true, binary: true, size: 3 }); + expect(result.content).toBeUndefined(); + }); + + it('caches results: a second call does not invoke fetch again', async () => { + const text = 'cached content'; + const bytes = new TextEncoder().encode(text); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + arrayBuffer: async () => bytes.buffer, + }); + vi.stubGlobal('fetch', fetchMock); + const { readRepoFile } = await import('@/lib/repo/fetch'); + + const first = await readRepoFile('/src/cached.ts'); + const second = await readRepoFile('/src/cached.ts'); + + expect(first.content).toBe(text); + expect(second).toBe(first); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('returns an error (and does not cache) on HTTP 404', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + arrayBuffer: async () => new ArrayBuffer(0), + }); + vi.stubGlobal('fetch', fetchMock); + const { readRepoFile } = await import('@/lib/repo/fetch'); + + const result = await readRepoFile('/missing.ts'); + + expect(result).toEqual({ ok: false, error: 'HTTP 404' }); + + // Not cached: a second call should hit fetch again. + await readRepoFile('/missing.ts'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('never throws: a rejecting fetch resolves to ok:false', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockRejectedValue(new Error('network down')), + ); + const { readRepoFile } = await import('@/lib/repo/fetch'); + + const result = await readRepoFile('/src/whatever.ts'); + + expect(result.ok).toBe(false); + expect(result.error).toBe('network down'); + }); + + it('never throws: an abort resolves to ok:false', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((_url, opts) => { + opts.signal.addEventListener('abort', () => { + const err = new Error('The operation was aborted'); + err.name = 'AbortError'; + }); + return Promise.reject(new Error('The operation was aborted')); + }), + ); + const { readRepoFile } = await import('@/lib/repo/fetch'); + + const result = await readRepoFile('/src/aborted.ts'); + + expect(result.ok).toBe(false); + expect(result.error).toBe('The operation was aborted'); + }); +}); diff --git a/src/__tests__/lib/repo/path.test.ts b/src/__tests__/lib/repo/path.test.ts new file mode 100644 index 0000000..586cea8 --- /dev/null +++ b/src/__tests__/lib/repo/path.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { + basename, + normalizePath, + parentPath, + resolvePath, + segments, + toRawPath, +} from '@/lib/repo/path'; + +describe('segments', () => { + it('returns [] for root', () => { + expect(segments('/')).toEqual([]); + }); + + it('splits a normal path', () => { + expect(segments('/src/app')).toEqual(['src', 'app']); + }); + + it('collapses empty segments', () => { + expect(segments('//a//b/')).toEqual(['a', 'b']); + }); + + it('returns [] for empty string', () => { + expect(segments('')).toEqual([]); + }); +}); + +describe('normalizePath', () => { + it('resolves "." and ".."', () => { + expect(normalizePath('/src/../app/./x')).toBe('/app/x'); + }); + + it('never goes above root', () => { + expect(normalizePath('/a/../../b')).toBe('/b'); + }); + + it('collapses duplicate slashes', () => { + expect(normalizePath('//x//')).toBe('/x'); + }); + + it('keeps root as root', () => { + expect(normalizePath('/')).toBe('/'); + }); + + it('resolves a deeply nested relative climb', () => { + expect(normalizePath('/a/b/c/../../d')).toBe('/a/d'); + }); +}); + +describe('resolvePath', () => { + it('resolves a relative input against cwd', () => { + expect(resolvePath('/src', 'app')).toBe('/src/app'); + }); + + it('"." resolves to cwd', () => { + expect(resolvePath('/src', '.')).toBe('/src'); + }); + + it('".." resolves to parent', () => { + expect(resolvePath('/src/app', '..')).toBe('/src'); + }); + + it('"~" resolves to root', () => { + expect(resolvePath('/src', '~')).toBe('/'); + }); + + it('"~/x" resolves to a root-relative path', () => { + expect(resolvePath('/src', '~/lib')).toBe('/lib'); + }); + + it('"~/" resolves to root', () => { + expect(resolvePath('/src', '~/')).toBe('/'); + }); + + it('absolute input ignores cwd', () => { + expect(resolvePath('/src', '/lib')).toBe('/lib'); + }); + + it('empty input returns cwd', () => { + expect(resolvePath('/src', '')).toBe('/src'); + }); + + it('resolves nested relative input', () => { + expect(resolvePath('/src/app', '../../lib')).toBe('/lib'); + }); +}); + +describe('parentPath', () => { + it('returns "/" for root', () => { + expect(parentPath('/')).toBe('/'); + }); + + it('returns "/" for a top-level dir', () => { + expect(parentPath('/src')).toBe('/'); + }); + + it('returns the parent for nested paths', () => { + expect(parentPath('/src/app')).toBe('/src'); + }); +}); + +describe('basename', () => { + it('returns "/" for root', () => { + expect(basename('/')).toBe('/'); + }); + + it('returns the last segment', () => { + expect(basename('/src/app')).toBe('app'); + }); + + it('returns the file name', () => { + expect(basename('/file.ts')).toBe('file.ts'); + }); +}); + +describe('toRawPath', () => { + it('strips the leading slash', () => { + expect(toRawPath('/src/app/page.tsx')).toBe('src/app/page.tsx'); + }); + + it('returns "" for root', () => { + expect(toRawPath('/')).toBe(''); + }); +}); diff --git a/src/__tests__/lib/repo/vfs.test.ts b/src/__tests__/lib/repo/vfs.test.ts new file mode 100644 index 0000000..a325994 --- /dev/null +++ b/src/__tests__/lib/repo/vfs.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import type { RepoEntry } from '@/lib/repo/types'; +import { createVfs, exists, getNode, isDir, listDir } from '@/lib/repo/vfs'; + +const SAMPLE: RepoEntry[] = [ + { path: 'README.md', type: 'blob', size: 100 }, + { path: 'src/index.ts', type: 'blob', size: 42 }, + { path: 'src/app/page.tsx', type: 'blob', size: 256 }, + { path: 'a/b/c.txt', type: 'blob', size: 7 }, +]; + +describe('createVfs', () => { + it('builds nodes for root, all entries, and derived dirs', () => { + const vfs = createVfs(SAMPLE); + + expect(getNode(vfs, '/')?.type).toBe('tree'); + expect(getNode(vfs, '/src')?.type).toBe('tree'); + expect(getNode(vfs, '/src/app')?.type).toBe('tree'); + expect(getNode(vfs, '/src/index.ts')).toEqual({ + path: 'src/index.ts', + type: 'blob', + size: 42, + }); + expect(getNode(vfs, '/a')?.type).toBe('tree'); + expect(getNode(vfs, '/a/b')?.type).toBe('tree'); + expect(getNode(vfs, '/a/b/c.txt')).toEqual({ + path: 'a/b/c.txt', + type: 'blob', + size: 7, + }); + }); + + it('returns undefined for unknown paths', () => { + const vfs = createVfs(SAMPLE); + expect(getNode(vfs, '/nope')).toBeUndefined(); + }); + + it('still has a root with empty entries', () => { + const vfs = createVfs([]); + expect(getNode(vfs, '/')?.type).toBe('tree'); + expect(exists(vfs, '/')).toBe(true); + }); +}); + +describe('exists', () => { + const vfs = createVfs(SAMPLE); + + it('returns true for present nodes', () => { + expect(exists(vfs, '/')).toBe(true); + expect(exists(vfs, '/src')).toBe(true); + expect(exists(vfs, '/README.md')).toBe(true); + }); + + it('returns false for missing nodes', () => { + expect(exists(vfs, '/missing')).toBe(false); + }); +}); + +describe('isDir', () => { + const vfs = createVfs(SAMPLE); + + it('returns true for tree nodes', () => { + expect(isDir(vfs, '/')).toBe(true); + expect(isDir(vfs, '/src')).toBe(true); + expect(isDir(vfs, '/src/app')).toBe(true); + }); + + it('returns false for blob nodes', () => { + expect(isDir(vfs, '/README.md')).toBe(false); + }); + + it('returns false for missing nodes', () => { + expect(isDir(vfs, '/missing')).toBe(false); + }); +}); + +describe('listDir', () => { + const vfs = createVfs(SAMPLE); + + it('lists root children sorted alphabetically', () => { + const entries = listDir(vfs, '/'); + expect(entries?.map((e) => e.path)).toEqual(['README.md', 'a', 'src']); + }); + + it('lists a nested dir sorted alphabetically', () => { + const entries = listDir(vfs, '/src'); + expect(entries?.map((e) => e.path)).toEqual(['src/app', 'src/index.ts']); + expect(entries?.find((e) => e.path === 'src/app')?.type).toBe('tree'); + expect(entries?.find((e) => e.path === 'src/index.ts')?.type).toBe('blob'); + }); + + it('returns undefined for a file path (not a dir)', () => { + expect(listDir(vfs, '/src/index.ts')).toBeUndefined(); + }); + + it('returns undefined for a missing path', () => { + expect(listDir(vfs, '/missing')).toBeUndefined(); + }); + + it('returns [] (not undefined) for an empty root', () => { + const vfs = createVfs([]); + expect(listDir(vfs, '/')).toEqual([]); + }); + + it('returns [] for a dir with no children', () => { + // /a/b/c.txt exists but /a/b has only c.txt as a child -> not empty. + // Verify a leaf dir lists its single child. + expect(listDir(vfs, '/a/b')?.map((e) => e.path)).toEqual(['a/b/c.txt']); + }); +}); diff --git a/src/__tests__/stores/repo-store.test.ts b/src/__tests__/stores/repo-store.test.ts new file mode 100644 index 0000000..1eeaca4 --- /dev/null +++ b/src/__tests__/stores/repo-store.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RepoEntry } from '@/lib/repo/types'; +import { createVfs, getNode } from '@/lib/repo/vfs'; +import { $cwd, $repoTree, changeDirectory } from '@/stores/repo-store'; + +const SAMPLE_ENTRIES: RepoEntry[] = [ + { path: 'README.md', type: 'blob', size: 10 }, + { path: 'src/index.ts', type: 'blob', size: 5 }, + { path: 'src/app/page.tsx', type: 'blob', size: 7 }, +]; + +describe('repo-store', () => { + describe('$cwd default', () => { + it('defaults to root', () => { + expect($cwd.get()).toBe('/'); + }); + }); + + describe('changeDirectory', () => { + beforeEach(() => { + $repoTree.set({ + vfs: createVfs(SAMPLE_ENTRIES), + commit: 'abc', + loading: false, + error: null, + }); + $cwd.set('/'); + }); + + it('moves into a top-level directory', () => { + const result = changeDirectory('src'); + expect(result).toEqual({ ok: true }); + expect($cwd.get()).toBe('/src'); + }); + + it('resolves relative paths against the current cwd', () => { + changeDirectory('src'); + const result = changeDirectory('app'); + expect(result).toEqual({ ok: true }); + expect($cwd.get()).toBe('/src/app'); + }); + + it('resolves ".. to the parent directory', () => { + changeDirectory('src'); + changeDirectory('app'); + const result = changeDirectory('..'); + expect(result).toEqual({ ok: true }); + expect($cwd.get()).toBe('/src'); + }); + + it('fails for a nonexistent target and leaves cwd unchanged', () => { + changeDirectory('src'); + const result = changeDirectory('nonexistent'); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/no such file or directory/); + expect($cwd.get()).toBe('/src'); + }); + + it('fails when the target is a file (not a directory)', () => { + const result = changeDirectory('README.md'); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/not a directory/); + expect($cwd.get()).toBe('/'); + }); + + it('goes home on empty argument from anywhere', () => { + changeDirectory('src'); + changeDirectory('app'); + const result = changeDirectory(''); + expect(result).toEqual({ ok: true }); + expect($cwd.get()).toBe('/'); + }); + + it('returns filesystem not ready when vfs is null', () => { + $repoTree.set({ + vfs: null, + commit: null, + loading: false, + error: null, + }); + const result = changeDirectory('src'); + expect(result).toEqual({ ok: false, error: 'filesystem not ready' }); + expect($cwd.get()).toBe('/'); + }); + }); + + describe('onMount load', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it('loads the vfs when the first subscriber attaches', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => ({ + owner: 'nipsysdev', + name: 'site', + commit: 'abc', + entries: [{ path: 'README.md', type: 'blob', size: 10 }], + }), + })), + ); + vi.resetModules(); + const { $repoTree } = await import('@/stores/repo-store'); + $repoTree.listen(() => {}); + + await vi.waitFor(() => { + expect($repoTree.get().loading).toBe(false); + }); + const state = $repoTree.get(); + expect(state.commit).toBe('abc'); + expect(state.error).toBeNull(); + expect(state.vfs).not.toBeNull(); + expect(getNode(state.vfs as never, '/README.md')).toBeDefined(); + }); + + it('sets an error and leaves vfs null when fetch fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('network down'); + }), + ); + vi.resetModules(); + const { $repoTree } = await import('@/stores/repo-store'); + $repoTree.listen(() => {}); + + await vi.waitFor(() => { + expect($repoTree.get().loading).toBe(false); + }); + const state = $repoTree.get(); + expect(state.error).not.toBeNull(); + expect(state.vfs).toBeNull(); + expect(state.loading).toBe(false); + }); + + it('sets an error on a non-ok HTTP response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: false, status: 404 })), + ); + vi.resetModules(); + const { $repoTree } = await import('@/stores/repo-store'); + $repoTree.listen(() => {}); + + await vi.waitFor(() => { + expect($repoTree.get().loading).toBe(false); + }); + const state = $repoTree.get(); + expect(state.error).toMatch(/HTTP 404/); + expect(state.vfs).toBeNull(); + }); + }); +}); diff --git a/src/__tests__/stores/terminal-store.test.ts b/src/__tests__/stores/terminal-store.test.ts index 29afbf2..64e243f 100644 --- a/src/__tests__/stores/terminal-store.test.ts +++ b/src/__tests__/stores/terminal-store.test.ts @@ -2,6 +2,9 @@ import { allTasks, cleanStores, keepMount } from 'nanostores'; import type { KeyboardEvent as ReactKeyboardEvent } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildCommandEntry } from '@/__tests__/fixtures/terminal-fixtures'; +import type { RepoEntry } from '@/lib/repo/types'; +import { createVfs } from '@/lib/repo/vfs'; +import { $cwd, $repoTree } from '@/stores/repo-store'; import { $terminalHistory, $terminalHistoryIdx, @@ -205,6 +208,68 @@ describe('terminal-store', () => { }); }); + describe('cd command', () => { + const CD_ENTRIES: RepoEntry[] = [ + { path: 'README.md', type: 'blob', size: 10 }, + { path: 'src/index.ts', type: 'blob', size: 5 }, + ]; + + beforeEach(() => { + $repoTree.set({ + vfs: createVfs(CD_ENTRIES), + commit: 'abc', + loading: false, + error: null, + }); + $cwd.set('/'); + $terminalHistory.set([]); + }); + + afterEach(() => { + $repoTree.set({ vfs: null, commit: null, loading: true, error: null }); + $cwd.set('/'); + }); + + it('changes directory and records the before-cwd on the entry', () => { + $terminalInput.set('cd src'); + + submitTerminalInput(); + + expect($cwd.get()).toBe('/src'); + const history = $terminalHistory.get(); + expect(history).toHaveLength(1); + expect(history[0].cmdName).toBe(Command.Cd); + expect(history[0].error).toBeUndefined(); + expect(history[0].cwd).toBe('/'); + }); + + it('records an error and leaves cwd unchanged for a missing target', () => { + $terminalInput.set('cd nope'); + + submitTerminalInput(); + + expect($cwd.get()).toBe('/'); + const history = $terminalHistory.get(); + expect(history).toHaveLength(1); + expect(history[0].cmdName).toBe(Command.Cd); + expect(history[0].error).toMatch(/cd:.*no such file or directory/); + expect(history[0].cwd).toBe('/'); + }); + + it('goes home on no argument from a subdirectory', () => { + $cwd.set('/src'); + $terminalInput.set('cd'); + + submitTerminalInput(); + + expect($cwd.get()).toBe('/'); + const history = $terminalHistory.get(); + expect(history[0].cmdName).toBe(Command.Cd); + expect(history[0].error).toBeUndefined(); + expect(history[0].cwd).toBe('/src'); + }); + }); + describe('History Navigation', () => { beforeEach(() => { $terminalHistory.set([ @@ -355,6 +420,127 @@ describe('terminal-store', () => { }); }); + describe('autocomplete (path completion)', () => { + const PATH_ENTRIES: RepoEntry[] = [ + { path: 'README.md', type: 'blob', size: 100 }, + { path: 'package.json', type: 'blob', size: 50 }, + { path: 'src', type: 'tree' }, + { path: 'src/index.ts', type: 'blob', size: 20 }, + { path: 'src/app', type: 'tree' }, + { path: 'src/lib', type: 'tree' }, + ]; + + beforeEach(() => { + $repoTree.set({ + vfs: createVfs(PATH_ENTRIES), + commit: 'abc', + loading: false, + error: null, + }); + $cwd.set('/'); + $terminalInput.set(''); + $terminalSuggestions.set(null); + }); + + afterEach(() => { + $repoTree.set({ vfs: null, commit: null, loading: true, error: null }); + $cwd.set('/'); + }); + + it('completes a file path for cat', () => { + $terminalInput.set('cat READ'); + + autocomplete(); + + expect($terminalInput.get()).toBe('cat README.md'); + expect($terminalSuggestions.get()).toBeNull(); + }); + + it('completes a directory path for cd with a trailing slash', () => { + $terminalInput.set('cd sr'); + + autocomplete(); + + expect($terminalInput.get()).toBe('cd src/'); + }); + + it('cd only completes directories (files are ignored)', () => { + $terminalInput.set('cd READ'); + + autocomplete(); + + expect($terminalInput.get()).toBe('cd READ'); + expect($terminalSuggestions.get()).toEqual([]); + }); + + it('shows "no match" when no file or folder matches', () => { + $terminalInput.set('cat zzz'); + + autocomplete(); + + expect($terminalInput.get()).toBe('cat zzz'); + expect($terminalSuggestions.get()).toEqual([]); + }); + + it('ls completes both files and directories', () => { + $terminalInput.set('ls sr'); + + autocomplete(); + + expect($terminalInput.get()).toBe('ls src/'); + }); + + it('shows multiple suggestions when ambiguous', () => { + $terminalInput.set('cat src/'); + + autocomplete(); + + expect($terminalInput.get()).toBe('cat src/'); + const suggestions = $terminalSuggestions.get(); + expect(suggestions).not.toBeNull(); + expect(suggestions).toEqual( + expect.arrayContaining(['app', 'index.ts', 'lib']), + ); + }); + + it('does not path-complete non-path commands (help)', () => { + $terminalInput.set('help READ'); + + autocomplete(); + + expect($terminalInput.get()).toBe('help READ'); + expect($terminalSuggestions.get()).toBeNull(); + }); + + it('resolves completion relative to the current cwd', () => { + $cwd.set('/src'); + $terminalInput.set('cat ap'); + + autocomplete(); + + expect($terminalInput.get()).toBe('cat app/'); + }); + + it('does not complete flags', () => { + $terminalInput.set('ls -'); + + autocomplete(); + + expect($terminalInput.get()).toBe('ls -'); + expect($terminalSuggestions.get()).toBeNull(); + }); + + it('does nothing when the filesystem is not loaded', () => { + $repoTree.set({ vfs: null, commit: null, loading: true, error: null }); + $terminalInput.set('cat READ'); + + autocomplete(); + + expect($terminalInput.get()).toBe('cat READ'); + expect($terminalSuggestions.get()).toBeNull(); + }); + }); + describe('Keyboard Event Effects', () => { it('submits input on Enter key', async () => { $terminalInput.set('help'); @@ -476,7 +662,8 @@ describe('terminal-store', () => { it('handles history navigation with commands that have options', () => { const entry = buildCommandEntry({ cmdName: Command.Help, - option: 'detailed', + args: { positional: ['detailed'], flags: [], options: {} }, + rawInput: 'help detailed', }); $terminalHistory.set([entry]); $terminalHistoryIdx.set(-1); @@ -489,8 +676,8 @@ describe('terminal-store', () => { it('handles history navigation with commands that have arguments', () => { const entry = buildCommandEntry({ cmdName: Command.Help, - argName: 'format', - argValue: 'json', + args: { positional: [], flags: [], options: { format: 'json' } }, + rawInput: 'help --format=json', }); $terminalHistory.set([entry]); $terminalHistoryIdx.set(-1); diff --git a/src/__tests__/utils/terminal-utils.test.ts b/src/__tests__/utils/terminal-utils.test.ts index 2b9b14b..d105f1b 100644 --- a/src/__tests__/utils/terminal-utils.test.ts +++ b/src/__tests__/utils/terminal-utils.test.ts @@ -1,12 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import HelpOutput from '@/components/cmd-outputs/HelpOutput'; -import { Command } from '@/types/terminal'; +import { Command, type CommandEntry } from '@/types/terminal'; import { getDisplayHost, getPastInputStr, getTerminalEntryInput, + isRecognizedCommand, newTerminalEntry, + parseArguments, parseTerminalEntry, + tokenize, unrecognizedTerminalEntry, } from '@/utils/terminal-utils'; @@ -18,17 +21,16 @@ describe('terminal-utils', () => { const entry = newTerminalEntry( Command.Help, HelpOutput, - 'detailed', - 'format', - 'json', + { positional: ['detailed'], flags: [], options: { format: 'json' } }, + 'help detailed --format=json', ); const afterTime = Date.now(); expect(entry.cmdName).toBe(Command.Help); expect(entry.output).toBe(HelpOutput); - expect(entry.option).toBe('detailed'); - expect(entry.argName).toBe('format'); - expect(entry.argValue).toBe('json'); + expect(entry.args.positional).toEqual(['detailed']); + expect(entry.args.options).toEqual({ format: 'json' }); + expect(entry.rawInput).toBe('help detailed --format=json'); expect(entry.timestamp).toBeGreaterThanOrEqual(beforeTime); expect(entry.timestamp).toBeLessThanOrEqual(afterTime); }); @@ -38,9 +40,10 @@ describe('terminal-utils', () => { expect(entry.cmdName).toBe(Command.Welcome); expect(entry.output).toBeUndefined(); - expect(entry.option).toBeUndefined(); - expect(entry.argName).toBeUndefined(); - expect(entry.argValue).toBeUndefined(); + expect(entry.args.positional).toEqual([]); + expect(entry.args.flags).toEqual([]); + expect(entry.args.options).toEqual({}); + expect(entry.rawInput).toBe(''); }); it('creates entry with command and output only', () => { @@ -48,31 +51,20 @@ describe('terminal-utils', () => { expect(entry.cmdName).toBe(Command.Help); expect(entry.output).toBe(HelpOutput); - expect(entry.option).toBeUndefined(); - expect(entry.argName).toBeUndefined(); + expect(entry.args.positional).toEqual([]); + expect(entry.rawInput).toBe(''); }); - it('creates entry with command and option only', () => { - const entry = newTerminalEntry(Command.Help, undefined, 'verbose'); + it('creates entry with command and args only', () => { + const entry = newTerminalEntry(Command.Help, undefined, { + positional: ['verbose'], + flags: [], + options: {}, + }); expect(entry.cmdName).toBe(Command.Help); expect(entry.output).toBeUndefined(); - expect(entry.option).toBe('verbose'); - }); - - it('creates entry with command and argument', () => { - const entry = newTerminalEntry( - Command.Help, - undefined, - undefined, - 'format', - 'json', - ); - - expect(entry.cmdName).toBe(Command.Help); - expect(entry.argName).toBe('format'); - expect(entry.argValue).toBe('json'); - expect(entry.option).toBeUndefined(); + expect(entry.args.positional).toEqual(['verbose']); }); it('always includes a timestamp', () => { @@ -88,9 +80,10 @@ describe('terminal-utils', () => { expect(entry.cmdName).toBe('foobar'); expect(entry.output).toBeUndefined(); - expect(entry.option).toBeUndefined(); - expect(entry.argName).toBeUndefined(); - expect(entry.argValue).toBeUndefined(); + expect(entry.args.positional).toEqual([]); + expect(entry.args.flags).toEqual([]); + expect(entry.args.options).toEqual({}); + expect(entry.rawInput).toBe('foobar'); }); it('creates entry with empty string command', () => { @@ -98,47 +91,209 @@ describe('terminal-utils', () => { expect(entry.cmdName).toBe(''); expect(entry.output).toBeUndefined(); + expect(entry.rawInput).toBe(''); }); it('creates entry with special characters in command', () => { const entry = unrecognizedTerminalEntry('foo-bar-baz'); expect(entry.cmdName).toBe('foo-bar-baz'); + expect(entry.rawInput).toBe('foo-bar-baz'); }); it('includes timestamp', () => { const entry = unrecognizedTerminalEntry('unknown'); expect(entry.timestamp).toBeTypeOf('number'); }); + + it('has empty args and rawInput equal to name', () => { + const entry = unrecognizedTerminalEntry('not-a-command'); + + expect(entry.args).toEqual({ + positional: [], + flags: [], + options: {}, + }); + expect(entry.rawInput).toBe('not-a-command'); + }); + }); + }); + + describe('tokenize', () => { + it('splits simple space-separated tokens', () => { + expect(tokenize('help detailed')).toEqual(['help', 'detailed']); + }); + + it('collapses multiple spaces', () => { + expect(tokenize('help detailed')).toEqual(['help', 'detailed']); + }); + + it('handles leading and trailing whitespace', () => { + expect(tokenize(' help ')).toEqual(['help']); + }); + + it('respects single quotes', () => { + expect(tokenize("cat 'my file.txt'")).toEqual(['cat', 'my file.txt']); + }); + + it('respects double quotes', () => { + expect(tokenize('cat "my file.txt"')).toEqual(['cat', 'my file.txt']); + }); + + it('returns empty array for empty string', () => { + expect(tokenize('')).toEqual([]); + }); + + it('returns empty array for whitespace-only string', () => { + expect(tokenize(' ')).toEqual([]); + }); + + it('preserves single quotes inside double quotes', () => { + expect(tokenize('echo "it\'s here"')).toEqual(['echo', "it's here"]); + }); + + it('preserves double quotes inside single quotes', () => { + expect(tokenize('echo \'say "hi"\'')).toEqual(['echo', 'say "hi"']); + }); + }); + + describe('parseArguments', () => { + it('returns positional args only', () => { + expect(parseArguments(['src', 'app', 'page.tsx'])).toEqual({ + positional: ['src', 'app', 'page.tsx'], + flags: [], + options: {}, + }); + }); + + it('splits grouped short flags -la into [l, a]', () => { + expect(parseArguments(['-la'])).toEqual({ + positional: [], + flags: ['l', 'a'], + options: {}, + }); + }); + + it('handles separate short flags -l -a', () => { + expect(parseArguments(['-l', '-a'])).toEqual({ + positional: [], + flags: ['l', 'a'], + options: {}, + }); + }); + + it('handles long flag --all', () => { + expect(parseArguments(['--all'])).toEqual({ + positional: [], + flags: ['all'], + options: {}, + }); + }); + + it('handles key=value option --key=value', () => { + expect(parseArguments(['--format=json'])).toEqual({ + positional: [], + flags: [], + options: { format: 'json' }, + }); + }); + + it('treats -- as end-of-options marker', () => { + expect(parseArguments(['--', '--weird', '-x'])).toEqual({ + positional: ['--weird', '-x'], + flags: [], + options: {}, + }); + }); + + it('mixes grouped short flags and positional args', () => { + expect(parseArguments(['-la', 'src'])).toEqual({ + positional: ['src'], + flags: ['l', 'a'], + options: {}, + }); + }); + + it('combines flags, options, and positional args', () => { + expect(parseArguments(['-l', '--format=json', 'src'])).toEqual({ + positional: ['src'], + flags: ['l'], + options: { format: 'json' }, + }); + }); + + it('returns empty result for empty tokens', () => { + expect(parseArguments([])).toEqual({ + positional: [], + flags: [], + options: {}, + }); + }); + + it('treats a lone dash as a positional arg', () => { + expect(parseArguments(['-'])).toEqual({ + positional: ['-'], + flags: [], + options: {}, + }); }); }); describe('Entry Parsing Functions', () => { describe('parseTerminalEntry', () => { - it('parses simple command without options or arguments', () => { + it('parses help command', () => { const entry = parseTerminalEntry('help'); expect(entry.cmdName).toBe(Command.Help); - expect(entry.option).toBeUndefined(); - expect(entry.argName).toBeUndefined(); - expect(entry.argValue).toBeUndefined(); + expect(entry.args.positional).toEqual([]); + }); + + it('parses whoami command', () => { + expect(parseTerminalEntry('whoami').cmdName).toBe(Command.Whoami); + }); + + it('parses contact command', () => { + expect(parseTerminalEntry('contact').cmdName).toBe(Command.Contact); + }); + + it('parses build-info command', () => { + expect(parseTerminalEntry('build-info').cmdName).toBe( + Command.BuildInfo, + ); + }); + + it('parses clear command', () => { + expect(parseTerminalEntry('clear').cmdName).toBe(Command.Clear); + }); + + it('parses welcome command', () => { + expect(parseTerminalEntry('welcome').cmdName).toBe(Command.Welcome); + }); + + it('parses status command', () => { + expect(parseTerminalEntry('status').cmdName).toBe(Command.Status); + }); + + it('parses gallery command', () => { + expect(parseTerminalEntry('gallery').cmdName).toBe(Command.Gallery); }); - it('parses command with option', () => { + it('parses resume command', () => { + expect(parseTerminalEntry('resume').cmdName).toBe(Command.Resume); + }); + + it('parses command with positional arg', () => { const entry = parseTerminalEntry('help detailed'); expect(entry.cmdName).toBe(Command.Help); - expect(entry.option).toBe('detailed'); - expect(entry.argName).toBeUndefined(); + expect(entry.args.positional).toEqual(['detailed']); }); - it('parses command with argument using --name=value syntax', () => { + it('parses command with --key=value option', () => { const entry = parseTerminalEntry('help --format=json'); expect(entry.cmdName).toBe(Command.Help); - expect(entry.argName).toBe('format'); - expect(entry.argValue).toBe('json'); - expect(entry.option).toBeUndefined(); + expect(entry.args.options).toEqual({ format: 'json' }); }); it('returns unrecognized entry for unknown command', () => { @@ -148,6 +303,12 @@ describe('terminal-utils', () => { expect(entry.output).toBeUndefined(); }); + it('returns full trimmed input as cmdName for unknown multi-word command', () => { + const entry = parseTerminalEntry('foo bar'); + + expect(entry.cmdName).toBe('foo bar'); + }); + it('handles empty string', () => { const entry = parseTerminalEntry(''); @@ -155,95 +316,89 @@ describe('terminal-utils', () => { expect(entry.output).toBeUndefined(); }); - it('handles command with multiple spaces', () => { + it('handles whitespace-only string', () => { + const entry = parseTerminalEntry(' '); + + expect(entry.cmdName).toBe(''); + }); + + it('collapses multiple spaces between tokens', () => { const entry = parseTerminalEntry('help detailed'); expect(entry.cmdName).toBe(Command.Help); - expect(entry.option).toBe(''); // Empty string from split + expect(entry.args.positional).toEqual(['detailed']); }); - it('parses whoami command', () => { - const entry = parseTerminalEntry('whoami'); + it('parses Unix-style grouped short flags -la src via a registered command', () => { + const entry = parseTerminalEntry('help -la src'); - expect(entry.cmdName).toBe(Command.Whoami); + expect(entry.cmdName).toBe(Command.Help); + expect(entry.args.flags).toEqual(['l', 'a']); + expect(entry.args.positional).toEqual(['src']); }); - it('parses contact command', () => { - const entry = parseTerminalEntry('contact'); + it('parses ls -la src into command, flags, and positional', () => { + const entry = parseTerminalEntry('ls -la src'); - expect(entry.cmdName).toBe(Command.Contact); + expect(entry.cmdName).toBe(Command.Ls); + expect(entry.args.flags).toEqual(['l', 'a']); + expect(entry.args.positional).toEqual(['src']); }); - it('parses build-info command', () => { - const entry = parseTerminalEntry('build-info'); + it('parses cat with a file path positional arg', () => { + const entry = parseTerminalEntry('cat src/app/page.tsx'); - expect(entry.cmdName).toBe(Command.BuildInfo); + expect(entry.cmdName).toBe(Command.Cat); + expect(entry.args.positional).toEqual(['src/app/page.tsx']); }); - it('parses clear command', () => { - const entry = parseTerminalEntry('clear'); + it('parses cd with a relative target positional arg', () => { + const entry = parseTerminalEntry('cd ../lib'); - expect(entry.cmdName).toBe(Command.Clear); + expect(entry.cmdName).toBe(Command.Cd); + expect(entry.args.positional).toEqual(['../lib']); }); - it('parses welcome command', () => { - const entry = parseTerminalEntry('welcome'); + it('parses quoted file path as single positional arg via a registered command', () => { + const entry = parseTerminalEntry('help "my file.txt"'); - expect(entry.cmdName).toBe(Command.Welcome); + expect(entry.cmdName).toBe(Command.Help); + expect(entry.args.positional).toEqual(['my file.txt']); }); - it('correctly processes help command with output', () => { - const entry = parseTerminalEntry('help'); + it('respects -- end-of-options marker via a registered command', () => { + const entry = parseTerminalEntry('help -- --weird'); expect(entry.cmdName).toBe(Command.Help); - expect(entry.timestamp).toBeTypeOf('number'); + expect(entry.args.flags).toEqual([]); + expect(entry.args.positional).toEqual(['--weird']); }); it('includes timestamp in parsed entry', () => { const entry = parseTerminalEntry('help'); expect(entry.timestamp).toBeTypeOf('number'); }); - }); - }); - describe('Input String Functions', () => { - describe('getTerminalEntryInput', () => { - it('returns command name only for simple entry', () => { - const entry = { - cmdName: Command.Help, - timestamp: Date.now(), - }; - - expect(getTerminalEntryInput(entry)).toBe('help'); - }); - - it('includes option when present', () => { - const entry = { - cmdName: Command.Help, - option: 'detailed', - timestamp: Date.now(), - }; + it('sets rawInput equal to trimmed input', () => { + const entry = parseTerminalEntry(' help detailed '); - expect(getTerminalEntryInput(entry)).toBe('help detailed'); + expect(entry.rawInput).toBe('help detailed'); }); - it('includes argument when present', () => { - const entry = { - cmdName: Command.Help, - argName: 'format', - argValue: 'json', - timestamp: Date.now(), - }; - - expect(getTerminalEntryInput(entry)).toBe('help --format=json'); + it('attaches the registered output component for valid commands', () => { + const entry = parseTerminalEntry('help'); + expect(entry.output).toBe(HelpOutput); }); + }); + }); - it('includes both option and argument when present', () => { + describe('Input String Functions', () => { + describe('getTerminalEntryInput', () => { + it('returns rawInput when present', () => { const entry = { cmdName: Command.Help, - option: 'detailed', - argName: 'format', - argValue: 'json', + args: { positional: [], flags: [], options: {} }, + rawInput: 'help detailed --format=json', timestamp: Date.now(), }; @@ -251,47 +406,70 @@ describe('terminal-utils', () => { 'help detailed --format=json', ); }); - }); - describe('getPastInputStr', () => { - it('returns command name only for simple entry', () => { + it('falls back to cmdName when rawInput is empty', () => { const entry = { cmdName: Command.Help, + args: { positional: [], flags: [], options: {} }, + rawInput: '', timestamp: Date.now(), }; - expect(getPastInputStr(entry)).toBe('help'); + expect(getTerminalEntryInput(entry)).toBe('help'); }); + }); - it('includes option when present', () => { + describe('getPastInputStr', () => { + it('returns rawInput when present', () => { const entry = { cmdName: Command.Help, - option: 'detailed', + args: { positional: [], flags: [], options: {} }, + rawInput: 'help detailed', timestamp: Date.now(), }; expect(getPastInputStr(entry)).toBe('help detailed'); }); - it('includes argument when present', () => { + it('falls back to cmdName when rawInput is empty', () => { const entry = { cmdName: Command.Help, - argName: 'format', - argValue: 'json', + args: { positional: [], flags: [], options: {} }, + rawInput: '', timestamp: Date.now(), }; - expect(getPastInputStr(entry)).toBe('help --format=json'); + expect(getPastInputStr(entry)).toBe('help'); }); + }); - it('matches getTerminalEntryInput behavior', () => { - const entries = [ - { cmdName: Command.Help, timestamp: Date.now() }, - { cmdName: Command.Help, option: 'detailed', timestamp: Date.now() }, + describe('getTerminalEntryInput and getPastInputStr parity', () => { + it('match each other for several entries', () => { + const entries: CommandEntry[] = [ { cmdName: Command.Help, - argName: 'format', - argValue: 'json', + args: { positional: [], flags: [], options: {} }, + rawInput: 'help', + timestamp: Date.now(), + }, + { + cmdName: Command.Help, + args: { + positional: ['detailed'], + flags: [], + options: {}, + }, + rawInput: 'help detailed', + timestamp: Date.now(), + }, + { + cmdName: Command.Help, + args: { + positional: [], + flags: [], + options: { format: 'json' }, + }, + rawInput: '', timestamp: Date.now(), }, ]; @@ -303,6 +481,31 @@ describe('terminal-utils', () => { }); }); + describe('isRecognizedCommand', () => { + it('returns true for a registered command', () => { + expect(isRecognizedCommand(Command.Help)).toBe(true); + }); + + it('returns true for clear (registered, no output)', () => { + expect(isRecognizedCommand(Command.Clear)).toBe(true); + }); + + it('returns true for the repo-browsing commands', () => { + expect(isRecognizedCommand(Command.Pwd)).toBe(true); + expect(isRecognizedCommand(Command.Ls)).toBe(true); + expect(isRecognizedCommand(Command.Cd)).toBe(true); + expect(isRecognizedCommand(Command.Cat)).toBe(true); + }); + + it('returns false for an unrecognized cast string', () => { + expect(isRecognizedCommand('foobar' as Command)).toBe(false); + }); + + it('returns false for an empty string', () => { + expect(isRecognizedCommand('' as Command)).toBe(false); + }); + }); + describe('Display Host Functions', () => { describe('getDisplayHost', () => { beforeEach(() => { diff --git a/src/components/cmd-outputs/CatOutput.tsx b/src/components/cmd-outputs/CatOutput.tsx new file mode 100644 index 0000000..ce291a3 --- /dev/null +++ b/src/components/cmd-outputs/CatOutput.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { useStore } from '@nanostores/react'; +import { Typography } from '@nipsys/lsd'; +import { useTranslations } from 'next-intl'; +import { useEffect, useMemo, useState } from 'react'; +import { buildRawUrl, readRepoFile } from '@/lib/repo/fetch'; +import { resolvePath } from '@/lib/repo/path'; +import { exists, isDir } from '@/lib/repo/vfs'; +import { $repoTree } from '@/stores/repo-store'; +import { $terminalPromptRef } from '@/stores/terminal-store'; +import type { CommandOutputProps } from '@/types/terminal'; + +type CatState = + | { status: 'loading' } + | { status: 'done'; content: string; size?: number } + | { status: 'binary'; size?: number } + | { status: 'error'; error: string }; + +export default function CatOutput({ entry }: CommandOutputProps) { + const t = useTranslations('Cat'); + const repoState = useStore($repoTree); + const cwd = entry.cwd ?? '/'; + const filename = entry.args.positional[0]; + const target = useMemo( + () => (filename ? resolvePath(cwd, filename) : ''), + [filename, cwd], + ); + const [state, setState] = useState({ status: 'loading' }); + + useEffect(() => { + if (!filename) return; + let cancelled = false; + const vfs = repoState.vfs; + if (!vfs) { + setState({ status: 'loading' }); + return; + } + if (!exists(vfs, target)) { + setState({ + status: 'error', + error: `cat: ${filename}: No such file or directory`, + }); + return; + } + if (isDir(vfs, target)) { + setState({ + status: 'error', + error: `cat: ${filename}: Is a directory`, + }); + return; + } + setState({ status: 'loading' }); + readRepoFile(target).then((result) => { + if (cancelled) return; + if (result.ok) { + if (result.binary) { + setState({ status: 'binary', size: result.size }); + } else { + setState({ + status: 'done', + content: result.content ?? '', + size: result.size, + }); + } + } else { + setState({ + status: 'error', + error: result.error ?? 'cat: read failed', + }); + } + }); + return () => { + cancelled = true; + }; + }, [filename, repoState.vfs, target]); + + useEffect(() => { + if (state.status !== 'done' && state.status !== 'binary') return; + // cat content loads asynchronously, after the submit-time scroll already + // fired — scroll the freshly-rendered content into view once it appears. + const id = setTimeout(() => { + $terminalPromptRef.get()?.current?.scrollIntoView(); + }); + return () => clearTimeout(id); + }, [state.status]); + + if (!filename) { + return ( + + cat: missing operand + + ); + } + + if (state.status === 'loading') { + return ( + + {t('loading', { file: filename })} + + ); + } + + if (state.status === 'error') { + return ( + + {state.error} + + ); + } + + if (state.status === 'binary') { + const rawUrl = buildRawUrl(target); + return ( + + cat: {filename}: binary file ({state.size ?? 0} bytes){' '} + + view raw + + + ); + } + + return ( +
+
+ {state.content} +
+
+ ); +} diff --git a/src/components/cmd-outputs/ContactOutput.tsx b/src/components/cmd-outputs/ContactOutput.tsx index 9467d70..f49a918 100644 --- a/src/components/cmd-outputs/ContactOutput.tsx +++ b/src/components/cmd-outputs/ContactOutput.tsx @@ -81,7 +81,7 @@ export default function ContactOutput() { icon: ChatTeardropTextIcon, labelKey: 'signal', href: 'https://signal.me/#eu/4FynXZ6lCD-qaR0x_CfvmEGVVtnprCVT4YRzyVrn7GVNB71oVHFAL6aP2soYBAI4', - displayText: 'nipsysdev.12', + displayText: 'nipsys.90', copyable: true, }, { diff --git a/src/components/cmd-outputs/LsOutput.tsx b/src/components/cmd-outputs/LsOutput.tsx new file mode 100644 index 0000000..3ab8d92 --- /dev/null +++ b/src/components/cmd-outputs/LsOutput.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useStore } from '@nanostores/react'; +import { Typography } from '@nipsys/lsd'; +import { useTranslations } from 'next-intl'; +import { basename, resolvePath } from '@/lib/repo/path'; +import type { RepoEntry } from '@/lib/repo/types'; +import { exists, isDir, listDir } from '@/lib/repo/vfs'; +import { $repoTree } from '@/stores/repo-store'; +import type { CommandOutputProps } from '@/types/terminal'; + +export default function LsOutput({ entry }: CommandOutputProps) { + const t = useTranslations('Ls'); + const repoState = useStore($repoTree); + const cwd = entry.cwd ?? '/'; + const positional0 = entry.args.positional[0]; + const flags = entry.args.flags; + + if (repoState.loading && !repoState.vfs) { + return ( + + {t('loading')} + + ); + } + + const vfs = repoState.vfs; + if (!vfs) { + return ( + + ls: filesystem unavailable + + ); + } + + const target = positional0 ? resolvePath(cwd, positional0) : cwd; + + if (!exists(vfs, target)) { + return ( + + ls: cannot access '{positional0}': No such file or directory + + ); + } + + if (!isDir(vfs, target)) { + return {basename(target)}; + } + + const showAll = flags.includes('a') || flags.includes('A'); + const longFormat = flags.includes('l'); + + const children = (listDir(vfs, target) ?? []).filter((child) => { + const name = basename(`/${child.path}`); + return showAll || !name.startsWith('.'); + }); + + const sizeLabel = (child: RepoEntry): string => { + if (child.type === 'tree') return '-'; + return String(child.size ?? 0); + }; + + if (longFormat) { + return ( +
+ {children.map((child) => { + const name = basename(`/${child.path}`); + const isDirChild = child.type === 'tree'; + return ( +
+ + {sizeLabel(child)} + + + {name} + +
+ ); + })} +
+ ); + } + + return ( +
+ {children.map((child) => { + const name = basename(`/${child.path}`); + const isDirChild = child.type === 'tree'; + return ( + + {name} + + ); + })} +
+ ); +} diff --git a/src/components/cmd-outputs/PwdOutput.tsx b/src/components/cmd-outputs/PwdOutput.tsx new file mode 100644 index 0000000..fccd794 --- /dev/null +++ b/src/components/cmd-outputs/PwdOutput.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { Typography } from '@nipsys/lsd'; +import type { CommandOutputProps } from '@/types/terminal'; + +export default function PwdOutput({ entry }: CommandOutputProps) { + return {entry.cwd ?? '/'}; +} diff --git a/src/components/cmd-outputs/WelcomeOutput.tsx b/src/components/cmd-outputs/WelcomeOutput.tsx index dce44f3..743b5c3 100644 --- a/src/components/cmd-outputs/WelcomeOutput.tsx +++ b/src/components/cmd-outputs/WelcomeOutput.tsx @@ -33,7 +33,7 @@ export default function WelcomeOutput() { Avatar - +

{t.rich('welcome', { name: (name) => {name}, diff --git a/src/components/terminal/CmdLink.tsx b/src/components/terminal/CmdLink.tsx index 3d6ce2e..2a46a20 100644 --- a/src/components/terminal/CmdLink.tsx +++ b/src/components/terminal/CmdLink.tsx @@ -15,7 +15,7 @@ export default function CmdLink(props: Props) { const cmd = props.cmdName ?? props.cmdInfo?.name ?? ''; if (!cmd) return; - if (props.cmdInfo?.options?.length) { + if (props.cmdInfo?.options?.length || props.cmdInfo?.usage) { $terminalInput.set(`${cmd} `); return; } @@ -40,12 +40,16 @@ export default function CmdLink(props: Props) { ); } - if (props.cmdInfo.options) { - const options = props.cmdInfo.options.join('|'); - return  {options}; + const parts: JSX.Element[] = []; + for (const flag of props.cmdInfo.options ?? []) { + parts.push( [{flag}]); + } + if (props.cmdInfo.usage) { + parts.push( {props.cmdInfo.usage}); } - return; + // biome-ignore lint/complexity/noUselessFragments: Fragment wrapping actually needed + return parts.length ? <>{parts} : undefined; }; return ( diff --git a/src/components/terminal/TerminalEmulator.tsx b/src/components/terminal/TerminalEmulator.tsx index 5771208..f2defee 100644 --- a/src/components/terminal/TerminalEmulator.tsx +++ b/src/components/terminal/TerminalEmulator.tsx @@ -1,15 +1,18 @@ 'use client'; import { useStore } from '@nanostores/react'; +import { Typography } from '@nipsys/lsd'; import { useTranslations } from 'next-intl'; import { useEffect, useRef, useState } from 'react'; import { $isAppReady } from '@/stores/app-store'; +import { $repoTree } from '@/stores/repo-store'; import { $terminalHistory, $terminalHistoryVisibleIdx, $terminalPromptRef, initializeTerminal, } from '@/stores/terminal-store'; +import { isRecognizedCommand } from '@/utils/terminal-utils'; import UnknownCmdOutput from '../cmd-outputs/UnknownCmdOutput'; import TerminalPrompt, { type TerminalPromptRef } from './TerminalPrompt'; @@ -50,6 +53,11 @@ export default function TerminalEmulator({ } }, [hasWindow, isAppReady, initialCommand]); + useEffect(() => { + const unsubscribe = $repoTree.listen(() => {}); + return unsubscribe; + }, []); + const focusTerminal = (target: HTMLElement) => { if ( !target.closest( @@ -69,7 +77,10 @@ export default function TerminalEmulator({ tabIndex={0} className="flex size-full cursor-default flex-col" onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { + if ( + e.target === e.currentTarget && + (e.key === 'Enter' || e.key === ' ') + ) { e.preventDefault(); mainPrompt.current?.focus(); } @@ -81,7 +92,11 @@ export default function TerminalEmulator({ {entry.output ? ( - ) : ( + ) : entry.error ? ( + + {entry.error} + + ) : isRecognizedCommand(entry.cmdName) ? null : ( entry.cmdName && )} diff --git a/src/components/terminal/TerminalPrompt.tsx b/src/components/terminal/TerminalPrompt.tsx index bcefa33..1e6176e 100644 --- a/src/components/terminal/TerminalPrompt.tsx +++ b/src/components/terminal/TerminalPrompt.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'; import { Typography } from '@nipsys/lsd'; import { forwardRef, useImperativeHandle, useRef } from 'react'; import type { Translator } from '@/i18n/intl'; +import { $cwd } from '@/stores/repo-store'; import { $terminalHistoryIdx, $terminalInput, @@ -28,6 +29,8 @@ const TerminalPrompt = forwardRef( const input = useStore($terminalInput); const suggestions = useStore($terminalSuggestions); const isReadOnly = useStore($terminalInputReadOnly); + const cwd = useStore($cwd); + const promptPath = entry ? (entry.cwd ?? '/') : cwd; const inputRef = useRef(null); const autocompleteRef = useRef(null); @@ -58,7 +61,7 @@ const TerminalPrompt = forwardRef( <>

- {i18n('visitor')}@{getDisplayHost()}:~$ + {`${i18n('visitor')}@${getDisplayHost()}:${promptPath}$`} ', + }, + { + name: Command.Cat, + output: CatOutput, + usage: '', + }, { name: Command.BuildInfo, output: BuildInfoOutput, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index f88ef2f..58341f4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -85,6 +85,12 @@ "build-info": { "description": "Show build timestamp and deployment info" }, + "cat": { + "description": "Print the contents of a file" + }, + "cd": { + "description": "Change the working directory" + }, "clear": { "description": "Clear the terminal screen" }, @@ -94,6 +100,12 @@ "help": { "description": "Print list of commands" }, + "ls": { + "description": "List directory contents" + }, + "pwd": { + "description": "Print the current working directory" + }, "welcome": { "description": "Print welcome message" }, @@ -174,5 +186,11 @@ "logosDeliverySuffix": ", a peer-to-peer & censorship-resistant messaging network.", "previousImage": "Previous image", "nextImage": "Next image" + }, + "Cat": { + "loading": "Please wait. Fetching {file} from GitHub." + }, + "Ls": { + "loading": "Please wait. Loading the repository..." } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 6f442dd..901bc92 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -85,6 +85,12 @@ "build-info": { "description": "Afficher la date du build et les informations de déploiement" }, + "cat": { + "description": "Afficher le contenu d'un fichier" + }, + "cd": { + "description": "Changer le répertoire de travail" + }, "clear": { "description": "Effacer l'écran du terminal" }, @@ -94,6 +100,12 @@ "help": { "description": "Afficher la liste des commandes" }, + "ls": { + "description": "Lister le contenu du répertoire" + }, + "pwd": { + "description": "Afficher le répertoire de travail actuel" + }, "welcome": { "description": "Afficher le message de bienvenue" }, @@ -174,5 +186,11 @@ "logosDeliverySuffix": ", un réseau de messagerie pair-à-pair et résistant à la censure.", "previousImage": "Image précédente", "nextImage": "Image suivante" + }, + "Cat": { + "loading": "Veuillez patienter. Récupération de {file} depuis GitHub." + }, + "Ls": { + "loading": "Veuillez patienter. Chargement du dépôt..." } } diff --git a/src/lib/repo/complete.ts b/src/lib/repo/complete.ts new file mode 100644 index 0000000..dd1cae3 --- /dev/null +++ b/src/lib/repo/complete.ts @@ -0,0 +1,52 @@ +import { resolvePath } from '@/lib/repo/path'; +import type { RepoEntry } from '@/lib/repo/types'; +import type { Vfs } from '@/lib/repo/vfs'; +import { isDir, listDir } from '@/lib/repo/vfs'; + +export type CompletionKind = 'all' | 'dir'; + +export interface PathCompletion { + /** Full token to insert when exactly one candidate matches; undefined otherwise. */ + completed?: string; + /** Candidate names (basename of each match) for the suggestions list. */ + suggestions: string[]; +} + +function nameOf(entry: RepoEntry): string { + const segments = entry.path.split('/'); + return segments[segments.length - 1] ?? entry.path; +} + +export function completePath( + vfs: Vfs, + cwd: string, + token: string, + kind: CompletionKind = 'all', +): PathCompletion { + const lastSlash = token.lastIndexOf('/'); + const dirPart = lastSlash >= 0 ? token.slice(0, lastSlash) : ''; + const prefix = lastSlash >= 0 ? token.slice(lastSlash + 1) : token; + + const dirAbs = dirPart === '' ? cwd : resolvePath(cwd, dirPart); + if (!isDir(vfs, dirAbs)) { + return { suggestions: [] }; + } + + const entries = (listDir(vfs, dirAbs) ?? []).filter((entry) => + kind === 'dir' ? entry.type === 'tree' : true, + ); + const matches = entries.filter((entry) => nameOf(entry).startsWith(prefix)); + + if (matches.length === 0) { + return { suggestions: [] }; + } + if (matches.length === 1) { + const name = nameOf(matches[0]); + const trailing = matches[0].type === 'tree' ? '/' : ''; + return { + completed: `${dirPart ? `${dirPart}/` : ''}${name}${trailing}`, + suggestions: [`${name}${trailing}`], + }; + } + return { suggestions: matches.map(nameOf) }; +} diff --git a/src/lib/repo/fetch.ts b/src/lib/repo/fetch.ts new file mode 100644 index 0000000..db0375c --- /dev/null +++ b/src/lib/repo/fetch.ts @@ -0,0 +1,65 @@ +import { toRawPath } from './path'; + +export const REPO_OWNER = 'nipsysdev'; +export const REPO_NAME = 'site'; + +export interface ReadResult { + ok: boolean; + content?: string; + /** true if detected binary */ + binary?: boolean; + /** bytes received */ + size?: number; + /** error message when ok === false */ + error?: string; +} + +export function buildRawUrl(absPath: string, ref?: string): string { + const resolvedRef = ref ?? process.env.BUILD_COMMIT ?? 'main'; + const rawPath = toRawPath(absPath); + return `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${resolvedRef}/${rawPath}`; +} + +export function detectBinary(bytes: Uint8Array): boolean { + const limit = Math.min(bytes.length, 8000); + for (let i = 0; i < limit; i++) { + if (bytes[i] === 0) return true; + } + return false; +} + +const contentCache = new Map(); + +export async function readRepoFile(absPath: string): Promise { + const cached = contentCache.get(absPath); + if (cached) return cached; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + + try { + const res = await fetch(buildRawUrl(absPath), { + signal: controller.signal, + }); + if (!res.ok) { + return { ok: false, error: `HTTP ${res.status}` }; + } + const buffer = await res.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const size = bytes.byteLength; + if (detectBinary(bytes)) { + const result: ReadResult = { ok: true, binary: true, size }; + contentCache.set(absPath, result); + return result; + } + const content = new TextDecoder('utf-8').decode(bytes); + const result: ReadResult = { ok: true, content, size }; + contentCache.set(absPath, result); + return result; + } catch (e) { + const message = e instanceof Error ? e.message : 'fetch failed'; + return { ok: false, error: message }; + } finally { + clearTimeout(timeout); + } +} diff --git a/src/lib/repo/path.ts b/src/lib/repo/path.ts new file mode 100644 index 0000000..6f24ba4 --- /dev/null +++ b/src/lib/repo/path.ts @@ -0,0 +1,61 @@ +/** Split an absolute VFS path into its non-empty segments. "/" -> []. "/src/app" -> ["src","app"] */ +export function segments(absPath: string): string[] { + return absPath.split('/').filter((s) => s.length > 0); +} + +/** + * Normalize an absolute path: collapse "//", resolve "." and ".." + * (never above root). "/src/../app/./x" -> "/app/x". "/" -> "/" + */ +export function normalizePath(absPath: string): string { + const segs = segments(absPath); + const out: string[] = []; + for (const seg of segs) { + if (seg === '.') continue; + if (seg === '..') { + out.pop(); + continue; + } + out.push(seg); + } + return `/${out.join('/')}`; +} + +/** + * Resolve an input path against cwd. Handles: "~", "~/x" (home=root), + * absolute "/x", relative, ".", "..". Empty input -> cwd. + */ +export function resolvePath(cwd: string, input: string): string { + if (input === '') return cwd; + let base: string; + if (input.startsWith('~')) { + let rest = input.slice(1); + if (rest.startsWith('/')) rest = rest.slice(1); + base = rest === '' ? '/' : `/${rest}`; + } else if (input.startsWith('/')) { + base = input; + } else { + base = `${cwd}/${input}`; + } + return normalizePath(base); +} + +/** Parent of an absolute path. "/" -> "/". "/src" -> "/". "/src/app" -> "/src" */ +export function parentPath(absPath: string): string { + const segs = segments(absPath); + if (segs.length === 0) return '/'; + segs.pop(); + return `/${segs.join('/')}`; +} + +/** Basename. "/" -> "/". "/src/app" -> "app" */ +export function basename(absPath: string): string { + const segs = segments(absPath); + if (segs.length === 0) return '/'; + return segs[segs.length - 1]; +} + +/** Convert absolute VFS path to repo-relative (strip leading slash). "/" -> "" */ +export function toRawPath(absPath: string): string { + return segments(absPath).join('/'); +} diff --git a/src/lib/repo/types.ts b/src/lib/repo/types.ts new file mode 100644 index 0000000..a1afda0 --- /dev/null +++ b/src/lib/repo/types.ts @@ -0,0 +1,16 @@ +export type RepoEntryType = 'blob' | 'tree'; + +export interface RepoEntry { + /** Repo-relative path, no leading slash, e.g. "src/app/page.tsx" */ + path: string; + type: RepoEntryType; + /** Size in bytes (blobs only) */ + size?: number; +} + +export interface RepoTreeData { + owner: string; + name: string; + commit: string; + entries: RepoEntry[]; +} diff --git a/src/lib/repo/vfs.ts b/src/lib/repo/vfs.ts new file mode 100644 index 0000000..5fba570 --- /dev/null +++ b/src/lib/repo/vfs.ts @@ -0,0 +1,86 @@ +import { + basename, + normalizePath, + parentPath, + segments, + toRawPath, +} from './path'; +import type { RepoEntry } from './types'; + +export interface Vfs { + /** absolute path (leading /) -> entry. Root "/" always present as a synthetic tree entry. */ + nodes: Map; + /** absolute dir path -> sorted array of child basenames */ + children: Map; +} + +/** Build a Vfs from repo-relative entries. Root "/" is always a tree. */ +export function createVfs(entries: RepoEntry[]): Vfs { + const nodes = new Map(); + const children = new Map(); + + // Seed root as a synthetic tree entry. + nodes.set('/', { path: '', type: 'tree' }); + + for (const entry of entries) { + const key = normalizePath(`/${entry.path}`); + nodes.set(key, { ...entry, path: toRawPath(key) }); + + // Derive intermediate directory nodes so the tree is walkable even + // when the source data omits explicit directory entries. + const segs = segments(key); + for (let i = 1; i < segs.length; i++) { + const dirKey = `/${segs.slice(0, i).join('/')}`; + if (!nodes.has(dirKey)) { + nodes.set(dirKey, { path: toRawPath(dirKey), type: 'tree' }); + } + } + } + + // Build child index: parent abs path -> child basenames. + for (const key of nodes.keys()) { + if (key === '/') continue; + const parent = parentPath(key); + const name = basename(key); + const arr = children.get(parent); + if (arr) { + arr.push(name); + } else { + children.set(parent, [name]); + } + } + + for (const arr of children.values()) { + arr.sort(); + } + + return { nodes, children }; +} + +/** Get the entry at an absolute path, or undefined. Root "/" returns a synthetic tree entry. */ +export function getNode(vfs: Vfs, absPath: string): RepoEntry | undefined { + return vfs.nodes.get(normalizePath(absPath)); +} + +export function exists(vfs: Vfs, absPath: string): boolean { + return getNode(vfs, absPath) !== undefined; +} + +export function isDir(vfs: Vfs, absPath: string): boolean { + const node = getNode(vfs, absPath); + return node !== undefined && node.type === 'tree'; +} + +/** + * List children of a directory as RepoEntry[]. Returns undefined if not a + * directory. Sorted alphabetically by name. + */ +export function listDir(vfs: Vfs, absPath: string): RepoEntry[] | undefined { + const norm = normalizePath(absPath); + const node = vfs.nodes.get(norm); + if (!node || node.type !== 'tree') return undefined; + const names = vfs.children.get(norm) ?? []; + return names + .map((name) => vfs.nodes.get(normalizePath(`${norm}/${name}`))) + .filter((n): n is RepoEntry => n !== undefined); +} diff --git a/src/stores/repo-store.ts b/src/stores/repo-store.ts new file mode 100644 index 0000000..7ec39a7 --- /dev/null +++ b/src/stores/repo-store.ts @@ -0,0 +1,73 @@ +import { atom, onMount } from 'nanostores'; +import { resolvePath } from '@/lib/repo/path'; +import type { RepoTreeData } from '@/lib/repo/types'; +import { createVfs, exists, isDir, type Vfs } from '@/lib/repo/vfs'; + +export interface RepoState { + vfs: Vfs | null; + commit: string | null; + loading: boolean; + error: string | null; +} + +export const $repoTree = atom({ + vfs: null, + commit: null, + loading: true, + error: null, +}); + +export const $cwd = atom('/'); + +onMount($repoTree, () => { + let aborted = false; + (async () => { + try { + const res = await fetch('/repo-tree.json'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as RepoTreeData; + if (aborted) return; + $repoTree.set({ + vfs: createVfs(data.entries), + commit: data.commit, + loading: false, + error: null, + }); + } catch (e) { + if (aborted) return; + $repoTree.set({ + vfs: null, + commit: null, + loading: false, + error: e instanceof Error ? e.message : 'failed to load repo tree', + }); + } + })(); + return () => { + aborted = true; + }; +}); + +export interface ChangeDirResult { + ok: boolean; + error?: string; +} + +/** Resolve & validate a cd target against the current cwd. Sets $cwd on success. Does NOT format the 'cd:' prefix — the caller does. */ +export function changeDirectory(arg: string): ChangeDirResult { + const { vfs } = $repoTree.get(); + if (!vfs) { + return { ok: false, error: 'filesystem not ready' }; + } + const cwd = $cwd.get(); + const trimmed = arg.trim(); + const target = trimmed === '' ? '/' : resolvePath(cwd, trimmed); + if (!exists(vfs, target)) { + return { ok: false, error: `no such file or directory: ${trimmed}` }; + } + if (!isDir(vfs, target)) { + return { ok: false, error: `not a directory: ${trimmed}` }; + } + $cwd.set(target); + return { ok: true }; +} diff --git a/src/stores/terminal-store.ts b/src/stores/terminal-store.ts index fe81275..b35d335 100644 --- a/src/stores/terminal-store.ts +++ b/src/stores/terminal-store.ts @@ -2,8 +2,10 @@ import { atom, effect } from 'nanostores'; import type { KeyboardEvent, RefObject } from 'react'; import type { TerminalPromptRef } from '@/components/terminal/TerminalPrompt'; import { Commands } from '@/constants/commands'; +import { completePath } from '@/lib/repo/complete'; +import { $cwd, $repoTree, changeDirectory } from '@/stores/repo-store'; import { Key } from '@/types/keyboard'; -import type { CommandEntry } from '@/types/terminal'; +import { Command, type CommandEntry } from '@/types/terminal'; import { getPastInputStr, parseTerminalEntry } from '@/utils/terminal-utils'; export const $terminalInput = atom(''); @@ -55,11 +57,21 @@ export function submitTerminalInput() { const currentInput = $terminalInput.get(); const currentHistory = $terminalHistory.get(); const terminalPromptRef = $terminalPromptRef.get(); + const parsed = parseTerminalEntry(currentInput); - if (currentInput !== 'clear') { - $terminalHistory.set([...currentHistory, parseTerminalEntry(currentInput)]); - } else { + if (parsed.cmdName === Command.Clear) { $terminalHistoryVisibleIdx.set(currentHistory.length); + } else if (parsed.cmdName === Command.Cd) { + const cwdBefore = $cwd.get(); + const target = parsed.args.positional[0] ?? ''; + const result = changeDirectory(target); + const entry: CommandEntry = { ...parsed, cwd: cwdBefore }; + if (!result.ok) { + entry.error = `cd: ${result.error}`; + } + $terminalHistory.set([...currentHistory, entry]); + } else { + $terminalHistory.set([...currentHistory, { ...parsed, cwd: $cwd.get() }]); } $terminalInput.set(''); setTimeout(() => { @@ -95,6 +107,7 @@ export function initializeTerminal(command: string) { $terminalHistory.set([]); $terminalHistoryIdx.set(-1); $terminalHistoryVisibleIdx.set(0); + $cwd.set('/'); simulateInput(command); } @@ -127,15 +140,68 @@ export function setNextHistoryEntry() { $terminalHistoryIdx.set(idx); } +/** Commands whose arguments are filesystem paths (eligible for path completion). */ +const PATH_COMMANDS = new Set([Command.Cd, Command.Cat, Command.Ls]); + export function autocomplete() { const input = $terminalInput.get(); - const matchedCmds = Commands.map((cmd) => cmd.name).filter((cmd) => { - return cmd.startsWith(input); - }); - if (matchedCmds.length === 1) { - $terminalInput.set(matchedCmds[0]); - } else { - $terminalSuggestions.set(matchedCmds); + const lastSpace = input.lastIndexOf(' '); + + // No space yet → complete command names. + if (lastSpace === -1) { + const matchedCmds = Commands.map((cmd) => cmd.name).filter((cmd) => + cmd.startsWith(input), + ); + if (matchedCmds.length === 1) { + $terminalInput.set(matchedCmds[0]); + } else { + $terminalSuggestions.set(matchedCmds); + $terminalPromptRef.get()?.current?.scrollIntoView(); + } + return; + } + + // A space is present → complete the last token as a path, but only for + // commands that take path arguments (cd / ls / cat). + const cmdToken = input.slice(0, input.indexOf(' ')); + const command = Object.values(Command).find((value) => value === cmdToken) as + | Command + | undefined; + + if (!command || !PATH_COMMANDS.has(command)) { + $terminalSuggestions.set(null); + return; + } + + const token = input.slice(lastSpace + 1); + if (token.startsWith('-')) { + // Flags are not completed. + $terminalSuggestions.set(null); + return; + } + + const { vfs } = $repoTree.get(); + if (!vfs) { + $terminalSuggestions.set(null); + return; + } + + const result = completePath( + vfs, + $cwd.get(), + token, + command === Command.Cd ? 'dir' : 'all', + ); + + if (result.completed !== undefined) { + $terminalInput.set(`${input.slice(0, lastSpace + 1)}${result.completed}`); + $terminalSuggestions.set(null); + } else if (result.suggestions.length > 0) { + $terminalSuggestions.set(result.suggestions); $terminalPromptRef.get()?.current?.scrollIntoView(); + } else { + // No matching file/folder — surface "no match" (empty array) like the + // command-name path does, instead of silently rendering nothing. + $terminalSuggestions.set([]); } } diff --git a/src/types/terminal.ts b/src/types/terminal.ts index 90579cc..7314e68 100644 --- a/src/types/terminal.ts +++ b/src/types/terminal.ts @@ -2,23 +2,39 @@ import type { ComponentType } from 'react'; export enum Command { BuildInfo = 'build-info', + Cat = 'cat', + Cd = 'cd', Clear = 'clear', Contact = 'contact', Gallery = 'gallery', Help = 'help', + Ls = 'ls', + Pwd = 'pwd', + Resume = 'resume', Status = 'status', Welcome = 'welcome', Whoami = 'whoami', - Resume = 'resume', +} + +export interface ParsedArguments { + /** Positional operands, e.g. file/directory paths. 'src' in `ls src` */ + positional: string[]; + /** Boolean flags, normalized without dashes: 'l' from -l or -la; 'all' from --all */ + flags: string[]; + /** Key-value options: { format: 'json' } from --format=json */ + options: Record; } export interface CommandEntry { timestamp: number; cmdName: Command; output?: CommandOutput; - option?: string; - argName?: string; - argValue?: string; + args: ParsedArguments; + rawInput: string; + /** Working directory at submit time — used to render the per-line prompt path. */ + cwd?: string; + /** Command error surfaced to the terminal (e.g. cd failures). */ + error?: string; } export interface CommandOutputProps { @@ -37,4 +53,6 @@ export interface CommandInfo { output?: CommandOutput; arguments?: CommandArgument[]; options?: string[]; + /** Positional argument hint shown after the command name, e.g. "[path]" or "". */ + usage?: string; } diff --git a/src/utils/terminal-utils.ts b/src/utils/terminal-utils.ts index b05dd51..c7c2526 100644 --- a/src/utils/terminal-utils.ts +++ b/src/utils/terminal-utils.ts @@ -1,64 +1,120 @@ import { Commands } from '@/constants/commands'; -import type { CommandEntry, CommandOutput } from '@/types/terminal'; +import type { + CommandEntry, + CommandOutput, + ParsedArguments, +} from '@/types/terminal'; import { Command } from '@/types/terminal'; +const EMPTY_ARGS: ParsedArguments = { positional: [], flags: [], options: {} }; + export function newTerminalEntry( name: Command, output?: CommandOutput, - option?: string, - argName?: string, - argValue?: string, + args: ParsedArguments = EMPTY_ARGS, + rawInput = '', ): CommandEntry { + return { cmdName: name, output, args, rawInput, timestamp: Date.now() }; +} + +export function unrecognizedTerminalEntry(name: string): CommandEntry { return { - cmdName: name, - output, - option, - argName, - argValue, + cmdName: name as Command, + output: undefined, + args: { positional: [], flags: [], options: {} }, + rawInput: name, timestamp: Date.now(), }; } -export function unrecognizedTerminalEntry(name: string): CommandEntry { - return newTerminalEntry(name as Command); +/** Split an input line into tokens, respecting single and double quotes and collapsing whitespace. */ +export function tokenize(input: string): string[] { + const tokens: string[] = []; + let current = ''; + let inSingle = false; + let inDouble = false; + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + continue; + } + if (ch === '"' && !inSingle) { + inDouble = !inDouble; + continue; + } + if (ch === ' ' && !inSingle && !inDouble) { + if (current.length > 0) { + tokens.push(current); + current = ''; + } + continue; + } + current += ch; + } + if (current.length > 0) tokens.push(current); + return tokens; } -export function parseTerminalEntry(entry: string): CommandEntry { - const split = entry.split(' '); - const cmdName = - Command[ - (Object.entries(Command).find(([, v]) => v === split[0]) ?? [ - '', - ])[0] as keyof typeof Command - ]; - const cmdInfo = Commands.find((cmd) => cmd.name === cmdName); - - if (!cmdName || !cmdInfo) return unrecognizedTerminalEntry(entry); +/** Parse the non-command tokens into positional args, flags, and options (getopt-style). */ +export function parseArguments(tokens: string[]): ParsedArguments { + const positional: string[] = []; + const flags: string[] = []; + const options: Record = {}; + let endOfOptions = false; + for (const token of tokens) { + if (endOfOptions) { + positional.push(token); + continue; + } + if (token === '--') { + endOfOptions = true; + continue; + } + if (token.startsWith('--')) { + const body = token.slice(2); + const eq = body.indexOf('='); + if (eq >= 0) { + options[body.slice(0, eq)] = body.slice(eq + 1); + } else { + flags.push(body); + } + continue; + } + if (token.startsWith('-') && token.length > 1) { + for (const c of token.slice(1)) flags.push(c); + continue; + } + positional.push(token); + } + return { positional, flags, options }; +} - let option: string | undefined; - let argName: string | undefined; - let argValue: string | undefined; +export function parseTerminalEntry(entry: string): CommandEntry { + const raw = entry.trim(); + const tokens = tokenize(raw); + const cmdToken = tokens[0] ?? ''; + const cmdName = Object.values(Command).find((v) => v === cmdToken) as + | Command + | undefined; + const cmdInfo = cmdName + ? Commands.find((cmd) => cmd.name === cmdName) + : undefined; - if (split[1]?.includes('--') && split[1]?.includes('=')) { - const argSplit = split[1].split('='); - argName = argSplit[0].replace('--', ''); - argValue = argSplit[1]; - } else { - option = split[1]; + if (!cmdName || !cmdInfo) { + return unrecognizedTerminalEntry(raw); } - - return newTerminalEntry(cmdName, cmdInfo.output, option, argName, argValue); + return { + cmdName, + output: cmdInfo.output, + args: parseArguments(tokens.slice(1)), + rawInput: raw, + timestamp: Date.now(), + }; } -export function getTerminalEntryInput(entry: CommandEntry) { - let pastInput = entry.cmdName as string; - if (entry.option) { - pastInput += ` ${entry.option}`; - } - if (entry.argName) { - pastInput += ` --${entry.argName}=${entry.argValue}`; - } - return pastInput; +export function getTerminalEntryInput(entry: CommandEntry): string { + return entry.rawInput || (entry.cmdName as string); } export function getDisplayHost(): string { @@ -74,12 +130,10 @@ export function getDisplayHost(): string { } export function getPastInputStr(entry: CommandEntry): string { - let pastInput = entry.cmdName as string; - if (entry.option) { - pastInput += ` ${entry.option}`; - } - if (entry.argName) { - pastInput += ` --${entry.argName}=${entry.argValue}`; - } - return pastInput; + return entry.rawInput || (entry.cmdName as string); +} + +/** True if the name matches a registered command (vs. unrecognized free text). */ +export function isRecognizedCommand(name: Command): boolean { + return Commands.some((cmd) => cmd.name === name); }