diff --git a/README.md b/README.md index d0a79b8..fb736cd 100644 --- a/README.md +++ b/README.md @@ -297,13 +297,15 @@ This package ships a small ink TUI for browsing CODESYS-exported ST. After insta phobiCS-tui # explicit project directory phobiCS-tui approve # diff prompt; exit 0 = accept, 1 = reject, 2 = error -Browser-mode keys: `j`/`k` (or `↓`/`↑`) move the cursor, `l`/`Enter`/`→` expand a device, `h`/`←` collapse, `q` quits. +Browser-mode keys: `j`/`k` (or `↓`/`↑`) move the cursor, `l`/`Enter`/`→` expand a device, `h`/`←` collapse, `/` filter POUs by name (Enter commits, Esc clears), `o` open the highlighted POU in `$EDITOR` (or VS Code), `d` diff against the same-named POU in another device, `r` re-scan `mcp-mirror/`, `?` toggle the help overlay, `q` quits. -Approve-mode keys: `y` accept, `n`/`q`/`Esc` reject. +Approve-mode keys: `y` accept, `n`/`q`/`Esc` reject, `v` toggle unified ↔ side-by-side diff. -The browser writes the current selection to `%LOCALAPPDATA%/codesys-mcp/tui-state.json` (Windows) or `$XDG_STATE_HOME/codesys-mcp/tui-state.json` (Linux/Mac, defaulting to `~/.local/state/...`). The MCP tool `get_user_selection` reads it so an agent can ground its actions in what the user is looking at. +The browser writes the current selection to `%LOCALAPPDATA%/codesys-mcp/tui-state.json` (Windows) or `$XDG_STATE_HOME/codesys-mcp/tui-state.json` (Linux/Mac, defaulting to `~/.local/state/...`). The MCP tool `get_user_selection` reads it so an agent can ground its actions in what the user is looking at. The header shows mirror staleness when the on-disk export is older than 10 s, and a yellow resize warning appears below 80×20. -Approve mode is opt-in for the MCP server's modifying tools — start the server with `--approve-edits` to wire it in. v0.1 gates only `set_pou_code`; the rest of the modifying tools land in a follow-up. Off by default. +The Viewer applies ST syntax highlighting (cyan keywords, magenta types, gray comments, yellow strings). + +Approve mode is opt-in for the MCP server's modifying tools — start the server with `--approve-edits` to wire it in. The v0.2 followup gates **all 9 modifying tools**: `create_pou`, `create_property`, `create_method`, `create_dut`, `create_gvl`, `create_folder`, `delete_object`, `rename_object`, `add_library` — plus the original `set_pou_code`. Each operation pops a y/n diff prompt; create/delete render as all-green/all-red one-sided diffs, rename as a del+add of the leaf name, and `set_pou_code` as a real diff against the existing mirror file. Off by default. ## MCP Tools diff --git a/package-lock.json b/package-lock.json index c5de1d0..014ff3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codesys-mcp-sp21-plus", - "version": "0.5.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codesys-mcp-sp21-plus", - "version": "0.5.0", + "version": "0.7.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", @@ -18,7 +18,8 @@ "zod": "^3.24.3" }, "bin": { - "codesys-mcp-sp21-plus": "dist/bin.js" + "codesys-mcp-sp21-plus": "dist/bin.js", + "phobiCS-tui": "dist/tui/index.js" }, "devDependencies": { "@types/diff": "^5.2.3", diff --git a/package.json b/package.json index 17c0df6..4ddae06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codesys-mcp-sp21-plus", - "version": "0.6.4", + "version": "0.7.0", "description": "Codesys-MCP-SP21+ -- fork of luke-harriman/Codesys-MCP carrying CODESYS V3.5 SP22 Patch 1 fixes (and forward-compat with later SPs): script-engine API drift, online/runtime tool auto-login, dual-SHA release classifier, set_pou_code omitted-decl wipe fix, add_library managed-overload, etc. MCP server for CODESYS with persistent UI instance and file-based IPC.", "main": "dist/server.js", "bin": { diff --git a/src/approve-gate.ts b/src/approve-gate.ts index 8806501..034395b 100644 --- a/src/approve-gate.ts +++ b/src/approve-gate.ts @@ -1,4 +1,5 @@ import * as fs from 'fs/promises'; +import * as os from 'os'; import * as path from 'path'; import { spawn } from 'child_process'; @@ -93,6 +94,78 @@ export interface RunGateOpts { spawnFn?: typeof spawnApproveTui; } +/** + * Generic approve-gate for tools that don't have a single existing-file -> proposed-file + * mapping. Renders an arbitrary "before" + "after" snapshot through the TUI's approve + * mode, with a descriptive temp-file name conveying the operation. + * + * For pure-create ops, oldText is ''. For pure-delete ops, newText is ''. For rename, + * both sides are short identifier names. The diff display always communicates the op + * intent visually (all-green for create, all-red for delete, single line each for rename). + */ +export interface ApproveGateOp { + /** Short slug used as the synthetic filename — drives what the user sees as ".st". */ + slug: string; + /** Pre-state. Empty string for create operations. */ + oldText: string; + /** Post-state. Empty string for delete operations. */ + newText: string; + /** Test-only override for the spawn function. */ + spawnFn?: typeof spawnApproveTui; +} + +export async function runApproveGateOp(op: ApproveGateOp): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'phobics-gate-')); + const safeSlug = op.slug.replace(/[^A-Za-z0-9._-]+/g, '_'); + const oldPath = path.join(dir, `${safeSlug}.before.st`); + const newPath = path.join(dir, `${safeSlug}.after.st`); + await fs.writeFile(oldPath, op.oldText, 'utf8'); + await fs.writeFile(newPath, op.newText, 'utf8'); + try { + const spawnFn = op.spawnFn ?? spawnApproveTui; + return await spawnFn(oldPath, newPath); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +/** + * MCP-tool-shaped wrapper around runApproveGateOp. + * + * Returns null when the operation should proceed (gate disabled, accepted, or + * mirror file missing for a non-set_pou_code op). Returns an MCP-shaped + * response object when the call should short-circuit (rejected -> isError=false + * with the user-rejection message; error -> isError=true with the spawn error). + */ +export interface BlockResponse { + content: Array<{ type: 'text'; text: string }>; + isError: boolean; +} + +export async function gateOpForTool(opts: { + enabled: boolean; + slug: string; + oldText: string; + newText: string; + spawnFn?: typeof spawnApproveTui; +}): Promise { + if (!opts.enabled) return null; + const gate = await runApproveGateOp({ + slug: opts.slug, + oldText: opts.oldText, + newText: opts.newText, + spawnFn: opts.spawnFn, + }); + if (gate.status === 'accepted' || gate.status === 'no-existing') return null; + if (gate.status === 'rejected') { + return { content: [{ type: 'text', text: gate.message }], isError: false }; + } + return { + content: [{ type: 'text', text: `Approve gate error: ${gate.message}` }], + isError: true, + }; +} + export async function runApproveGate(opts: RunGateOpts): Promise { const existingPath = await findMirrorFile(opts.projectFilePath, opts.pouPath); if (!existingPath) { diff --git a/src/server.ts b/src/server.ts index c7b24e9..f5142c9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,7 +26,7 @@ import { inspectProjectFile } from './inspect'; import { parseProfileName } from './detect'; import { decideOpenProjectPreflight } from './preflight'; import { readSelection } from './state-read'; -import { runApproveGate } from './approve-gate'; +import { runApproveGate, gateOpForTool } from './approve-gate'; /** * Classifier for `bump_project_version --level=auto`. @@ -1046,6 +1046,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `create-pou-${sanParentPath.replace(/[/\\]/g, '_')}-${args.name}`, + oldText: '', + newText: `(* create POU *)\nname: ${args.name}\ntype: ${args.type}\nlanguage: ${args.language}\nparent: ${sanParentPath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1162,6 +1169,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `create-property-${sanParentPath.replace(/[/\\]/g, '_')}-${args.propertyName}`, + oldText: '', + newText: `(* create Property *)\nname: ${args.propertyName}\ntype: ${args.propertyType}\nparent FB: ${sanParentPath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1194,6 +1208,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `create-method-${sanParentPath.replace(/[/\\]/g, '_')}-${args.methodName}`, + oldText: '', + newText: `(* create Method *)\nname: ${args.methodName}\nreturn type: ${args.returnType ?? '(none)'}\nparent FB: ${sanParentPath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1363,6 +1384,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `create-dut-${sanParentPath.replace(/[/\\]/g, '_')}-${args.name}`, + oldText: '', + newText: `(* create DUT *)\nname: ${args.name}\ntype: ${args.dutType}\nparent: ${sanParentPath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1408,6 +1436,14 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `create-gvl-${sanParentPath.replace(/[/\\]/g, '_')}-${args.name}`, + oldText: '', + newText: `(* create GVL *)\nname: ${args.name}\nparent: ${sanParentPath}\n` + + (args.declarationCode ? `\nVAR_GLOBAL\n${args.declarationCode}\nEND_VAR\n` : ''), + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1438,6 +1474,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `create-folder-${sanParentPath.replace(/[/\\]/g, '_')}-${args.folderName}`, + oldText: '', + newText: `(* create Folder *)\nname: ${args.folderName}\nparent: ${sanParentPath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1466,6 +1509,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `delete-${sanObjPath.replace(/[/\\]/g, '_')}`, + oldText: `(* DELETE *)\nobject: ${sanObjPath}\nproject: ${args.projectFilePath}\n`, + newText: '', + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1501,6 +1551,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `rename-${sanObjPath.replace(/[/\\]/g, '_')}`, + oldText: `(* old name *)\n${sanObjPath}\n`, + newText: `(* new name *)\n${sanObjPath.replace(/[^/\\]+$/, args.newName)}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1969,6 +2026,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `add-library-${args.libraryName.replace(/[^A-Za-z0-9._-]+/g, '_')}`, + oldText: '', + newText: `(* add Library *)\nname: ${args.libraryName}\nproject: ${args.projectFilePath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); // Pick wording from the script's branch (dedup vs add) instead of // always saying "added" -- script emits "Library Already Present" diff --git a/src/tui/approve/Approve.tsx b/src/tui/approve/Approve.tsx index 3c74182..744ede7 100644 --- a/src/tui/approve/Approve.tsx +++ b/src/tui/approve/Approve.tsx @@ -16,8 +16,13 @@ export function Approve({ fileName, oldText, newText, onDecision }: ApproveProps const hunks = React.useMemo(() => computeHunks(oldText, newText), [oldText, newText]); const adds = hunks.filter((h) => h.kind === 'add').length; const dels = hunks.filter((h) => h.kind === 'del').length; + const [sideBySide, setSideBySide] = React.useState(false); useInput((input, key) => { + if (input === 'v') { + setSideBySide((v) => !v); + return; + } if (input === 'y') return onDecision('accept'); if (input === 'n' || input === 'q' || key.escape) return onDecision('reject'); }); @@ -27,18 +32,24 @@ export function Approve({ fileName, oldText, newText, onDecision }: ApproveProps ─ Approve change? {fileName} ─── + {adds} lines, − {dels} lines ─ - - {hunks.map((h, i) => ( - - ))} - + {sideBySide ? : } - y accept n reject q reject & quit ESC reject + y accept n reject v toggle side-by-side q reject & quit ESC reject ); } +function UnifiedView({ hunks }: { hunks: Hunk[] }): React.ReactElement { + return ( + + {hunks.map((h, i) => ( + + ))} + + ); +} + function HunkLine({ hunk }: { hunk: Hunk }): React.ReactElement { const sigil = hunk.kind === 'add' ? '+' : hunk.kind === 'del' ? '-' : ' '; const color = hunk.kind === 'add' ? 'green' : hunk.kind === 'del' ? 'red' : undefined; @@ -49,3 +60,68 @@ function HunkLine({ hunk }: { hunk: Hunk }): React.ReactElement { ); } + +function pairForSideBySide(hunks: Hunk[]): Array<[Hunk | null, Hunk | null]> { + const out: Array<[Hunk | null, Hunk | null]> = []; + let i = 0; + while (i < hunks.length) { + if (hunks[i].kind === 'ctx') { + out.push([hunks[i], hunks[i]]); + i++; + continue; + } + const dels: Hunk[] = []; + const adds: Hunk[] = []; + while (i < hunks.length && hunks[i].kind !== 'ctx') { + if (hunks[i].kind === 'del') dels.push(hunks[i]); + else adds.push(hunks[i]); + i++; + } + const max = Math.max(dels.length, adds.length); + for (let j = 0; j < max; j++) { + out.push([dels[j] ?? null, adds[j] ?? null]); + } + } + return out; +} + +function SideBySide({ hunks }: { hunks: Hunk[] }): React.ReactElement { + const rows = React.useMemo(() => pairForSideBySide(hunks), [hunks]); + return ( + + {rows.map((row, i) => ( + + ))} + + ); +} + +function SideBySideRow({ left, right }: { left: Hunk | null; right: Hunk | null }): React.ReactElement { + return ( + + + + + + + + + + ); +} + +function HalfLine({ hunk, side }: { hunk: Hunk | null; side: 'left' | 'right' }): React.ReactElement { + if (!hunk) return ; + const color = + hunk.kind === 'add' ? 'green' : hunk.kind === 'del' ? 'red' : undefined; + const sigil = + hunk.kind === 'add' ? '+' : hunk.kind === 'del' ? '-' : ' '; + // For ctx, show ' '; for del on left, show '-'; for add on right, show '+'. + // (Cross-cell pollution like 'add' on the left side shouldn't happen given pairForSideBySide.) + void side; + return ( + + {sigil} {String(hunk.lineNo).padStart(4, ' ')} {hunk.text} + + ); +} diff --git a/src/tui/browser/Browser.tsx b/src/tui/browser/Browser.tsx index d574c53..920510c 100644 --- a/src/tui/browser/Browser.tsx +++ b/src/tui/browser/Browser.tsx @@ -1,14 +1,18 @@ import React from 'react'; -import { Box, Text, useInput } from 'ink'; -import { Project, POU, Selection } from '../shared/types.js'; +import { Box, Text, useInput, useStdout } from 'ink'; +import { Project, POU, Selection, Hunk } from '../shared/types.js'; import { Tree, devicePath, pouPath } from './Tree.js'; import { Viewer } from './Viewer.js'; +import { formatStaleness, ResizeWarning } from './Statusbar.js'; +import { computeHunks } from '../shared/diff.js'; export interface BrowserProps { project: Project; readPou: (pou: POU) => Promise; writeSelection: (s: Selection) => void; onQuit: () => void; + onRescan?: () => void; + onOpenInEditor?: (absPath: string) => void; } interface FlatRow { @@ -18,25 +22,66 @@ interface FlatRow { pou?: POU; } -function flatten(project: Project, expanded: Set): FlatRow[] { +function flatten(project: Project, expanded: Set, filter: string): FlatRow[] { + const f = filter.toLowerCase(); const rows: FlatRow[] = []; for (const dev of project.devices) { + const matchingPous = f + ? dev.pous.filter((p) => p.name.toLowerCase().includes(f)) + : dev.pous; + if (f && matchingPous.length === 0) continue; rows.push({ path: devicePath(dev.name), kind: 'device', device: dev.name }); - if (!expanded.has(devicePath(dev.name))) continue; - for (const p of dev.pous) { + const isExpanded = f ? true : expanded.has(devicePath(dev.name)); + if (!isExpanded) continue; + for (const p of matchingPous) { rows.push({ path: pouPath(dev.name, p.relPath), kind: 'pou', device: dev.name, pou: p }); } } return rows; } -export function Browser({ project, readPou, writeSelection, onQuit }: BrowserProps): React.ReactElement { +export function Browser({ project, readPou, writeSelection, onQuit, onRescan, onOpenInEditor }: BrowserProps): React.ReactElement { const [expanded, setExpanded] = React.useState>(new Set()); const [cursorIdx, setCursorIdx] = React.useState(0); const [text, setText] = React.useState(null); const [scrollTop] = React.useState(0); + const [helpOpen, setHelpOpen] = React.useState(false); + const [filterMode, setFilterMode] = React.useState(false); + const [filter, setFilter] = React.useState(''); + const [crossDiff, setCrossDiff] = React.useState<{ + leftLabel: string; + rightLabel: string; + leftText: string; + rightText: string; + } | null>(null); + const [crossPicker, setCrossPicker] = React.useState<{ + pou: POU; + candidates: Array<{ device: string; pou: POU }>; + cursor: number; + } | null>(null); - const rows = React.useMemo(() => flatten(project, expanded), [project, expanded]); + const filteredProject = React.useMemo(() => { + if (!filter) return project; + const f = filter.toLowerCase(); + return { + ...project, + devices: project.devices + .map((d) => ({ ...d, pous: d.pous.filter((p) => p.name.toLowerCase().includes(f)) })) + .filter((d) => d.pous.length > 0), + }; + }, [project, filter]); + + const effectiveExpanded = React.useMemo(() => { + if (!filter) return expanded; + const next = new Set(expanded); + for (const d of filteredProject.devices) next.add(devicePath(d.name)); + return next; + }, [expanded, filter, filteredProject]); + + const rows = React.useMemo( + () => flatten(filteredProject, effectiveExpanded, ''), + [filteredProject, effectiveExpanded] + ); const cursor = rows[Math.min(cursorIdx, rows.length - 1)]; React.useEffect(() => { @@ -61,8 +106,119 @@ export function Browser({ project, readPou, writeSelection, onQuit }: BrowserPro }; }, [cursor, readPou]); + const openCrossDiff = React.useCallback( + async (currentDevice: string, currentPou: POU) => { + const candidates: Array<{ device: string; pou: POU }> = []; + for (const dev of project.devices) { + if (dev.name === currentDevice) continue; + const peer = dev.pous.find((p) => p.name === currentPou.name); + if (peer) candidates.push({ device: dev.name, pou: peer }); + } + if (candidates.length === 0) return; + if (candidates.length === 1) { + const peer = candidates[0]; + const [a, b] = await Promise.all([readPou(currentPou), readPou(peer.pou)]); + setCrossDiff({ + leftLabel: `${currentDevice}/${currentPou.name}`, + rightLabel: `${peer.device}/${peer.pou.name}`, + leftText: a, + rightText: b, + }); + return; + } + setCrossPicker({ pou: currentPou, candidates, cursor: 0 }); + }, + [project, readPou] + ); + useInput((input, key) => { + if (crossDiff) { + if (key.escape || input === 'q') setCrossDiff(null); + return; + } + if (crossPicker) { + if (key.escape) { + setCrossPicker(null); + return; + } + if (input === 'j' || key.downArrow) { + setCrossPicker((p) => + p ? { ...p, cursor: Math.min(p.cursor + 1, p.candidates.length - 1) } : p + ); + return; + } + if (input === 'k' || key.upArrow) { + setCrossPicker((p) => (p ? { ...p, cursor: Math.max(p.cursor - 1, 0) } : p)); + return; + } + if (key.return) { + const peer = crossPicker.candidates[crossPicker.cursor]; + const cur = cursor; + if (cur?.kind === 'pou' && cur.pou) { + const currentDevice = cur.device; + const currentPou = cur.pou; + Promise.all([readPou(currentPou), readPou(peer.pou)]) + .then(([a, b]) => { + setCrossDiff({ + leftLabel: `${currentDevice}/${currentPou.name}`, + rightLabel: `${peer.device}/${peer.pou.name}`, + leftText: a, + rightText: b, + }); + setCrossPicker(null); + }) + .catch(() => setCrossPicker(null)); + } + return; + } + return; + } + if (filterMode) { + if (key.escape) { + setFilter(''); + setFilterMode(false); + return; + } + if (key.return) { + setFilterMode(false); + return; + } + if (key.backspace || key.delete) { + setFilter((s) => s.slice(0, -1)); + return; + } + if (input && !key.ctrl && !key.meta) { + setFilter((s) => s + input); + return; + } + return; + } + if (input === '?') { + setHelpOpen((v) => !v); + return; + } + if (helpOpen) { + if (key.escape) setHelpOpen(false); + return; + } + if (input === '/') { + setFilterMode(true); + setCursorIdx(0); + return; + } + if (key.escape && filter) { + setFilter(''); + return; + } if (input === 'q') return onQuit(); + if (input === 'r' && onRescan) return onRescan(); + if (input === 'o' && onOpenInEditor && cursor?.kind === 'pou' && cursor.pou) { + return onOpenInEditor(cursor.pou.absPath); + } + if (input === 'd' && cursor?.kind === 'pou' && cursor.pou) { + void openCrossDiff(cursor.device, cursor.pou); + return; + } if (input === 'j' || key.downArrow) { setCursorIdx((i) => Math.min(i + 1, rows.length - 1)); } else if (input === 'k' || key.upArrow) { @@ -86,18 +242,110 @@ export function Browser({ project, readPou, writeSelection, onQuit }: BrowserPro } }); + const { stdout } = useStdout(); + const columns = stdout?.columns ?? 80; + const termRows = stdout?.rows ?? 24; + const stale = formatStaleness(project.mirrorMtimeMs); + return ( - ─ {project.rootDir.split(/[/\\]/).pop()} ─ + + ─ {project.rootDir.split(/[/\\]/).pop()} ─{stale ? ` mirror ${stale} old ` : ' '}─ + + + {helpOpen && } + {crossDiff && ( + + )} + {crossPicker && } + {(filterMode || filter) && ( + + Filter: {filter}{filterMode ? '_' : ''} + + )} - + - j/k nav l expand h collapse q quit + j/k nav l expand h collapse / filter o open d cross-diff r rescan ? help q quit + + ); +} + +function CrossPicker({ + state, +}: { + state: { pou: POU; candidates: Array<{ device: string; pou: POU }>; cursor: number }; +}): React.ReactElement { + return ( + + Cross-device diff: pick peer for {state.pou.name} + {state.candidates.map((c, i) => ( + + {i === state.cursor ? '▶ ' : ' '} + {c.device}/{c.pou.name} + + ))} + j/k pick Enter open Esc cancel + + ); +} + +function CrossDeviceDiff({ + leftLabel, + rightLabel, + leftText, + rightText, +}: { + leftLabel: string; + rightLabel: string; + leftText: string; + rightText: string; +}): React.ReactElement { + const hunks = React.useMemo(() => computeHunks(leftText, rightText), [leftText, rightText]); + const adds = hunks.filter((h) => h.kind === 'add').length; + const dels = hunks.filter((h) => h.kind === 'del').length; + return ( + + Cross-device diff: {leftLabel} → {rightLabel} (+{adds} −{dels}) + {hunks.map((h, i) => { + const sigil = h.kind === 'add' ? '+' : h.kind === 'del' ? '-' : ' '; + const color = h.kind === 'add' ? 'green' : h.kind === 'del' ? 'red' : undefined; + return ( + + {sigil} {String(h.lineNo).padStart(4, ' ')} {h.text} + + ); + })} + q / Esc close + + ); +} + +function HelpOverlay(): React.ReactElement { + return ( + + Keybindings + j / ↓ move cursor down + k / ↑ move cursor up + l / → expand device + h / ← collapse device + / filter POU list (Enter commits, Esc clears) + o open highlighted POU in $EDITOR (or VS Code) + d diff highlighted POU against same-named POU in another device + r re-scan mcp-mirror/ + ? toggle this help + Esc close help + q quit ); } diff --git a/src/tui/browser/Statusbar.tsx b/src/tui/browser/Statusbar.tsx new file mode 100644 index 0000000..c97a53a --- /dev/null +++ b/src/tui/browser/Statusbar.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Text } from 'ink'; + +export const STALE_THRESHOLD_MS = 10_000; +export const MIN_COLUMNS = 80; +export const MIN_ROWS = 20; + +export function formatStaleness(mirrorMtimeMs: number): string | null { + const ageMs = Date.now() - mirrorMtimeMs; + if (ageMs < STALE_THRESHOLD_MS) return null; + + const seconds = Math.floor(ageMs / 1000); + if (seconds < 60) return `${seconds}s`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + const remSec = seconds % 60; + return `${minutes}m ${remSec}s`; + } + + const hours = Math.floor(minutes / 60); + const remMin = minutes % 60; + return `${hours}h ${remMin}m`; +} + +export interface ResizeWarningProps { + columns: number; + rows: number; +} + +export function ResizeWarning({ columns, rows }: ResizeWarningProps): React.ReactElement | null { + if (columns < MIN_COLUMNS) { + return Terminal too narrow ({columns} cols, need {MIN_COLUMNS}+); + } + if (rows < MIN_ROWS) { + return Terminal too short ({rows} rows, need {MIN_ROWS}+); + } + return null; +} diff --git a/src/tui/browser/Viewer.tsx b/src/tui/browser/Viewer.tsx index a2cc331..657d121 100644 --- a/src/tui/browser/Viewer.tsx +++ b/src/tui/browser/Viewer.tsx @@ -1,6 +1,28 @@ import React from 'react'; import { Box, Text } from 'ink'; import { POU } from '../shared/types.js'; +import { tokenize, TokenKind } from './highlight.js'; + +const COLORS: Record = { + keyword: 'cyan', + type: 'magenta', + comment: 'gray', + string: 'yellow', + text: undefined, +}; + +function HighlightedLine({ line }: { line: string }): React.ReactElement { + const tokens = React.useMemo(() => tokenize(line), [line]); + return ( + + {tokens.map((t, i) => ( + + {t.text} + + ))} + + ); +} export interface ViewerProps { pou: POU | null; @@ -26,7 +48,8 @@ export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): Reac {slice.map((l, i) => ( - {String(scrollTop + i + 1).padStart(4, ' ')} {l} + {String(scrollTop + i + 1).padStart(4, ' ')} + ))} diff --git a/src/tui/browser/highlight.ts b/src/tui/browser/highlight.ts new file mode 100644 index 0000000..61dccb6 --- /dev/null +++ b/src/tui/browser/highlight.ts @@ -0,0 +1,109 @@ +export type TokenKind = 'keyword' | 'type' | 'comment' | 'string' | 'text'; + +export interface Token { + kind: TokenKind; + text: string; +} + +const KEYWORDS = new Set([ + 'PROGRAM', 'END_PROGRAM', + 'FUNCTION', 'END_FUNCTION', + 'FUNCTION_BLOCK', 'END_FUNCTION_BLOCK', + 'METHOD', 'END_METHOD', + 'PROPERTY', 'END_PROPERTY', + 'INTERFACE', 'END_INTERFACE', + 'STRUCT', 'END_STRUCT', + 'TYPE', 'END_TYPE', + 'ACTION', 'END_ACTION', + 'VAR', 'VAR_INPUT', 'VAR_OUTPUT', 'VAR_IN_OUT', 'VAR_GLOBAL', 'VAR_TEMP', 'VAR_CONFIG', 'VAR_EXTERNAL', 'VAR_STAT', 'END_VAR', + 'IF', 'THEN', 'ELSIF', 'ELSE', 'END_IF', + 'CASE', 'OF', 'END_CASE', + 'FOR', 'TO', 'BY', 'DO', 'END_FOR', + 'WHILE', 'END_WHILE', + 'REPEAT', 'UNTIL', 'END_REPEAT', + 'RETURN', 'EXIT', 'CONTINUE', + 'AND', 'OR', 'XOR', 'NOT', 'MOD', + 'TRUE', 'FALSE', + 'CONSTANT', 'RETAIN', 'PERSISTENT', 'ABSTRACT', 'FINAL', 'PUBLIC', 'PRIVATE', 'PROTECTED', 'INTERNAL', + 'EXTENDS', 'IMPLEMENTS', 'GET', 'SET', 'REFERENCE', 'POINTER', 'ARRAY', + 'WITH', 'AT', + 'SUPER', 'THIS', +]); + +const TYPES = new Set([ + 'BOOL', 'BYTE', 'WORD', 'DWORD', 'LWORD', + 'SINT', 'INT', 'DINT', 'LINT', + 'USINT', 'UINT', 'UDINT', 'ULINT', + 'REAL', 'LREAL', + 'TIME', 'LTIME', 'DATE', 'TIME_OF_DAY', 'TOD', 'LTOD', 'DATE_AND_TIME', 'DT', 'LDT', + 'STRING', 'WSTRING', 'CHAR', 'WCHAR', + 'ANY', 'ANY_BIT', 'ANY_INT', 'ANY_NUM', 'ANY_REAL', 'ANY_STRING', +]); + +const IDENT_RE = /[A-Z_][A-Z0-9_]*/; + +export function tokenize(line: string): Token[] { + const out: Token[] = []; + let i = 0; + let pending = ''; + + const flushPending = () => { + if (!pending) return; + // Walk pending and split at every uppercase identifier boundary, so + // identifiers (whether keyword/type or plain) come out as their own + // tokens. Anything else (whitespace, punctuation, lowercase ids) is + // emitted as 'text'. + const re = /[A-Z_][A-Z0-9_]*/g; + let last = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(pending)) !== null) { + const word = m[0]; + const start = m.index; + if (start > last) out.push({ kind: 'text', text: pending.slice(last, start) }); + const kind: TokenKind = KEYWORDS.has(word) ? 'keyword' : TYPES.has(word) ? 'type' : 'text'; + out.push({ kind, text: word }); + last = start + word.length; + } + if (last < pending.length) out.push({ kind: 'text', text: pending.slice(last) }); + pending = ''; + }; + + while (i < line.length) { + // (* ... *) inline comment + if (line[i] === '(' && line[i + 1] === '*') { + flushPending(); + const end = line.indexOf('*)', i + 2); + if (end < 0) { + out.push({ kind: 'comment', text: line.slice(i) }); + i = line.length; + } else { + out.push({ kind: 'comment', text: line.slice(i, end + 2) }); + i = end + 2; + } + continue; + } + // // line comment + if (line[i] === '/' && line[i + 1] === '/') { + flushPending(); + out.push({ kind: 'comment', text: line.slice(i) }); + i = line.length; + continue; + } + // 'single' or "double" strings (no escape handling beyond doubled quotes) + if (line[i] === "'" || line[i] === '"') { + flushPending(); + const quote = line[i]; + let end = i + 1; + while (end < line.length && line[end] !== quote) end++; + const close = end < line.length ? end + 1 : end; + out.push({ kind: 'string', text: line.slice(i, close) }); + i = close; + continue; + } + pending += line[i]; + i++; + } + flushPending(); + // Normalize: empty trailing 'text' tokens are fine; we keep them so the line's column structure is preserved. + return out; +} diff --git a/src/tui/index.tsx b/src/tui/index.tsx index a3d81f9..7944523 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render } from 'ink'; import * as fs from 'fs/promises'; +import { spawn } from 'child_process'; import { Approve, Decision } from './approve/Approve.js'; import { Browser } from './browser/Browser.js'; import { walk } from './shared/scan.js'; @@ -13,7 +14,7 @@ const argv = process.argv.slice(2); async function main(): Promise { if (argv[0] === '--version' || argv[0] === '-v') { - process.stdout.write('phobiCS-tui v0.1.0\n'); + process.stdout.write('phobiCS-tui v0.2.0\n'); return 0; } if (argv[0] === 'approve') return runApprove(argv[1], argv[2]); @@ -51,12 +52,40 @@ async function runBrowser(maybeRoot: string | undefined): Promise { resolve(0); }; const readPou = (pou: { absPath: string }) => fs.readFile(pou.absPath, 'utf8'); + const onOpenInEditor = (absPath: string) => { + const editor = process.env.EDITOR || 'code'; + try { + const child = spawn(editor, [absPath], { stdio: 'ignore', detached: true, shell: true }); + child.unref(); + } catch (err) { + process.stderr.write(`phobiCS-tui: open-in-editor failed: ${(err as Error).message}\n`); + } + }; + const onRescan = async () => { + try { + const next = await walk(root); + app.rerender( + + ); + } catch (err) { + process.stderr.write(`phobiCS-tui: rescan failed: ${(err as Error).message}\n`); + } + }; const app = render( ); }); diff --git a/tests/tui/Approve.test.tsx b/tests/tui/Approve.test.tsx index 129ebb5..e51c58d 100644 --- a/tests/tui/Approve.test.tsx +++ b/tests/tui/Approve.test.tsx @@ -61,6 +61,33 @@ describe('', () => { expect(decision).toHaveBeenCalledWith('reject'); }); + it('toggles to side-by-side on v and shows both halves', async () => { + const { stdin, lastFrame } = render( + {}} /> + ); + await flush(); + expect(lastFrame()).not.toContain('│'); + stdin.write('v'); + await flush(); + const out = lastFrame()!; + expect(out).toContain('│'); + expect(out).toContain('counter : INT := 0;'); + expect(out).toContain('counter : DINT := 0;'); + }); + + it('toggles back to unified on a second v', async () => { + const { stdin, lastFrame } = render( + {}} /> + ); + await flush(); + stdin.write('v'); + await flush(); + expect(lastFrame()).toContain('│'); + stdin.write('v'); + await flush(); + expect(lastFrame()).not.toContain('│'); + }); + it('calls onDecision("reject") on escape', async () => { const decision = vi.fn(); const { stdin } = render( diff --git a/tests/tui/Browser.test.tsx b/tests/tui/Browser.test.tsx index 08c0ff5..e1b07d6 100644 --- a/tests/tui/Browser.test.tsx +++ b/tests/tui/Browser.test.tsx @@ -11,8 +11,14 @@ const project: Project = { { name: 'D1', pous: [ - { name: 'PLC_PRG', kind: 'PRG', relPath: 'PLC_PRG.st', absPath: '/abs/PLC_PRG.st', loc: 5, mtimeMs: 0 }, - { name: 'FB_X', kind: 'FB', relPath: 'FB_X.st', absPath: '/abs/FB_X.st', loc: 9, mtimeMs: 0 }, + { name: 'PLC_PRG', kind: 'PRG', relPath: 'PLC_PRG.st', absPath: '/abs/D1/PLC_PRG.st', loc: 5, mtimeMs: 0 }, + { name: 'FB_X', kind: 'FB', relPath: 'FB_X.st', absPath: '/abs/D1/FB_X.st', loc: 9, mtimeMs: 0 }, + ], + }, + { + name: 'D2', + pous: [ + { name: 'PLC_PRG', kind: 'PRG', relPath: 'PLC_PRG.st', absPath: '/abs/D2/PLC_PRG.st', loc: 5, mtimeMs: 0 }, ], }, ], @@ -79,4 +85,169 @@ describe('', () => { await flush(); expect(onQuit).toHaveBeenCalled(); }); + + it('d on a POU with one same-name peer in another device opens the cross-device diff', async () => { + const reads: Record = { + '/abs/D1/PLC_PRG.st': 'PROGRAM PLC_PRG\nVAR\n v : INT;\nEND_VAR', + '/abs/D2/PLC_PRG.st': 'PROGRAM PLC_PRG\nVAR\n v : DINT;\nEND_VAR', + }; + const { stdin, lastFrame } = render( + reads[pou.absPath] ?? ''} + writeSelection={() => {}} + onQuit={() => {}} + /> + ); + await flush(); + stdin.write('l'); + await flush(); + stdin.write('j'); + await flush(); + stdin.write('d'); + await flush(); + // wait for both readPou promises to settle + await new Promise((r) => setTimeout(r, 50)); + const out = lastFrame()!; + expect(out).toMatch(/Cross-device diff/); + expect(out).toContain('D1'); + expect(out).toContain('D2'); + }); + + it('toggles a help overlay on ?', async () => { + const { stdin, lastFrame } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + /> + ); + await flush(); + expect(lastFrame()).not.toContain('Keybindings'); + stdin.write('?'); + await flush(); + expect(lastFrame()).toContain('Keybindings'); + stdin.write('?'); + await flush(); + expect(lastFrame()).not.toContain('Keybindings'); + }); + + it('/ enters filter mode; typed chars filter the POU list', async () => { + const { stdin, lastFrame } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + /> + ); + await flush(); + stdin.write('/'); + await flush(); + expect(lastFrame()).toMatch(/Filter:/); + stdin.write('F'); + stdin.write('B'); + await flush(); + expect(lastFrame()).toContain('FB_X'); + expect(lastFrame()).not.toContain('PLC_PRG'); + }); + + it('Esc cancels filter mode and clears the filter', async () => { + const { stdin, lastFrame } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + /> + ); + await flush(); + stdin.write('l'); + await flush(); + stdin.write('/'); + await flush(); + stdin.write('F'); + await flush(); + stdin.write('B'); + await flush(); + expect(lastFrame()).not.toContain('PLC_PRG'); + stdin.write(String.fromCharCode(27)); + await flush(); + expect(lastFrame()).toContain('PLC_PRG'); + expect(lastFrame()).not.toMatch(/Filter:/); + }); + + it('calls onOpenInEditor on o with the highlighted POU absPath', async () => { + const onOpenInEditor = vi.fn(); + const { stdin } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + onOpenInEditor={onOpenInEditor} + /> + ); + await flush(); + stdin.write('l'); + await flush(); + stdin.write('j'); + await flush(); + stdin.write('o'); + await flush(); + expect(onOpenInEditor).toHaveBeenCalledWith('/abs/D1/PLC_PRG.st'); + }); + + it('does not call onOpenInEditor when cursor is on a device row', async () => { + const onOpenInEditor = vi.fn(); + const { stdin } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + onOpenInEditor={onOpenInEditor} + /> + ); + await flush(); + stdin.write('o'); + await flush(); + expect(onOpenInEditor).not.toHaveBeenCalled(); + }); + + it('calls onRescan on r', async () => { + const onRescan = vi.fn(); + const { stdin } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + onRescan={onRescan} + /> + ); + await flush(); + stdin.write('r'); + await flush(); + expect(onRescan).toHaveBeenCalled(); + }); + + it('closes the help overlay on Esc', async () => { + const { stdin, lastFrame } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + /> + ); + await flush(); + stdin.write('?'); + await flush(); + expect(lastFrame()).toContain('Keybindings'); + stdin.write(String.fromCharCode(27)); + await flush(); + expect(lastFrame()).not.toContain('Keybindings'); + }); }); diff --git a/tests/tui/Statusbar.test.tsx b/tests/tui/Statusbar.test.tsx new file mode 100644 index 0000000..4f2892d --- /dev/null +++ b/tests/tui/Statusbar.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render } from 'ink-testing-library'; +import { + formatStaleness, + ResizeWarning, + STALE_THRESHOLD_MS, + MIN_COLUMNS, + MIN_ROWS, +} from '../../src/tui/browser/Statusbar.tsx'; + +describe('formatStaleness', () => { + it('returns null when mirror is fresh', () => { + expect(formatStaleness(Date.now() - 1_000)).toBeNull(); + expect(formatStaleness(Date.now() - (STALE_THRESHOLD_MS - 1))).toBeNull(); + }); + + it('returns "Xs" when mirror is between threshold and 60s old', () => { + const ageMs = Math.max(STALE_THRESHOLD_MS, 30_000); + const out = formatStaleness(Date.now() - ageMs); + expect(out).toMatch(/\d+s/); + }); + + it('returns "Xm Ys" when mirror is minutes old', () => { + const out = formatStaleness(Date.now() - 5 * 60_000 - 12_000); + expect(out).toMatch(/5m/); + }); + + it('returns "Xh Ym" when mirror is hours old', () => { + const out = formatStaleness(Date.now() - 2 * 3600_000 - 30 * 60_000); + expect(out).toMatch(/2h/); + }); +}); + +describe('', () => { + it('renders nothing when terminal is large enough', () => { + const { lastFrame } = render(); + expect(lastFrame()).toBe(''); + }); + + it('warns when columns are below the minimum', () => { + const { lastFrame } = render(); + expect(lastFrame()).toMatch(/Terminal too narrow/); + }); + + it('warns when rows are below the minimum', () => { + const { lastFrame } = render(); + expect(lastFrame()).toMatch(/Terminal too short/); + }); +}); diff --git a/tests/tui/highlight.test.ts b/tests/tui/highlight.test.ts new file mode 100644 index 0000000..b0f7b73 --- /dev/null +++ b/tests/tui/highlight.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { tokenize, TokenKind } from '../../src/tui/browser/highlight'; + +function kinds(line: string): TokenKind[] { + return tokenize(line).map((t) => t.kind); +} + +function texts(line: string): string[] { + return tokenize(line).map((t) => t.text); +} + +describe('tokenize', () => { + it('returns a single text token for plain identifiers', () => { + expect(tokenize('foo bar baz')).toEqual([{ kind: 'text', text: 'foo bar baz' }]); + }); + + it('flags ST keywords (uppercase)', () => { + const ts = tokenize('PROGRAM PLC_PRG'); + expect(ts.find((t) => t.text === 'PROGRAM')?.kind).toBe('keyword'); + expect(ts.find((t) => t.text === 'PLC_PRG')?.kind).toBe('text'); + }); + + it('flags END_VAR / END_IF compound keywords', () => { + const ts = tokenize('END_VAR END_IF END_FOR'); + expect(ts.filter((t) => t.kind === 'keyword').map((t) => t.text)).toEqual([ + 'END_VAR', + 'END_IF', + 'END_FOR', + ]); + }); + + it('flags IEC types', () => { + const ts = tokenize('counter : INT := 0;'); + expect(ts.find((t) => t.text === 'INT')?.kind).toBe('type'); + }); + + it('does not flag lowercase variants of keywords', () => { + const ts = tokenize('program plc_prg'); + expect(ts.every((t) => t.kind !== 'keyword')).toBe(true); + }); + + it('captures (* ... *) comments inline', () => { + expect(kinds('x := 1; (* a comment *) y := 2;')).toContain('comment'); + expect(texts('x := 1; (* a comment *) y := 2;')).toContain('(* a comment *)'); + }); + + it('captures // line comments to end of line', () => { + const ts = tokenize('x := 1; // trailing'); + expect(ts.at(-1)?.kind).toBe('comment'); + expect(ts.at(-1)?.text).toBe('// trailing'); + }); + + it("captures 'single-quoted' and \"double-quoted\" strings", () => { + expect(texts("s := 'hello';")).toContain("'hello'"); + expect(texts('s := "hi";')).toContain('"hi"'); + }); +}); diff --git a/tests/unit/approve-gate.test.ts b/tests/unit/approve-gate.test.ts index dd4d3bc..a08f30a 100644 --- a/tests/unit/approve-gate.test.ts +++ b/tests/unit/approve-gate.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest'; -import { composeMergedContent, IMPL_SENTINEL } from '../../src/approve-gate'; +import { describe, it, expect, vi } from 'vitest'; +import * as fs from 'fs/promises'; +import { composeMergedContent, IMPL_SENTINEL, runApproveGateOp } from '../../src/approve-gate'; const existing = [ 'PROGRAM PLC_PRG', @@ -47,3 +48,53 @@ describe('composeMergedContent', () => { expect(out).toContain('x := 1;'); }); }); + +describe('runApproveGateOp', () => { + it('writes before/after files and spawns the TUI with both paths', async () => { + const captured: { existing?: string; staged?: string } = {}; + const spawnFn = vi.fn(async (existingPath: string, stagedPath: string) => { + captured.existing = existingPath; + captured.staged = stagedPath; + const oldText = await fs.readFile(existingPath, 'utf8'); + const newText = await fs.readFile(stagedPath, 'utf8'); + expect(oldText).toBe('OLD'); + expect(newText).toBe('NEW'); + return { status: 'accepted' as const }; + }); + const result = await runApproveGateOp({ + slug: 'create-Application/MyFB', + oldText: 'OLD', + newText: 'NEW', + spawnFn, + }); + expect(result.status).toBe('accepted'); + expect(spawnFn).toHaveBeenCalledOnce(); + }); + + it('passes the rejected status through', async () => { + const spawnFn = vi.fn(async () => ({ + status: 'rejected' as const, + message: 'nope', + })); + const result = await runApproveGateOp({ + slug: 'delete-Foo', + oldText: 'something', + newText: '', + spawnFn, + }); + expect(result.status).toBe('rejected'); + }); + + it('cleans up the temp dir after the spawn returns', async () => { + let dirSeen: string | undefined; + const spawnFn = vi.fn(async (existingPath: string) => { + dirSeen = existingPath.replace(/[\\/][^\\/]+$/, ''); + // dir should exist while we're in here + await fs.access(dirSeen); + return { status: 'accepted' as const }; + }); + await runApproveGateOp({ slug: 'x', oldText: '', newText: 'Y', spawnFn }); + // After: the dir must be gone + await expect(fs.access(dirSeen!)).rejects.toThrow(); + }); +});