Merge pull request #2 from phobicdotno/feature/phobics-tui-followup
phobiCS-tui v0.2 followup: TUI keybinds + approve-gate for all 9 modifying tools
This commit is contained in:
commit
aaf76d2eec
16 changed files with 1051 additions and 31 deletions
10
README.md
10
README.md
|
|
@ -297,13 +297,15 @@ This package ships a small ink TUI for browsing CODESYS-exported ST. After insta
|
|||
phobiCS-tui <projectDir> # explicit project directory
|
||||
phobiCS-tui approve <a.st> <b.st> # 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
|
||||
|
||||
|
|
|
|||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 "<file>.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<GateResult> {
|
||||
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<BlockResponse | null> {
|
||||
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<GateResult> {
|
||||
const existingPath = await findMirrorFile(opts.projectFilePath, opts.pouPath);
|
||||
if (!existingPath) {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
},
|
||||
['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<void> {
|
|||
},
|
||||
['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<void> {
|
|||
},
|
||||
['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<void> {
|
|||
},
|
||||
['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<void> {
|
|||
},
|
||||
['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<void> {
|
|||
},
|
||||
['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<void> {
|
|||
},
|
||||
['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<void> {
|
|||
},
|
||||
['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<void> {
|
|||
},
|
||||
['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"
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<Text>
|
||||
─ Approve change? {fileName} ─── + {adds} lines, − {dels} lines ─
|
||||
</Text>
|
||||
<Box flexDirection="column">
|
||||
{hunks.map((h, i) => (
|
||||
<HunkLine key={i} hunk={h} />
|
||||
))}
|
||||
</Box>
|
||||
{sideBySide ? <SideBySide hunks={hunks} /> : <UnifiedView hunks={hunks} />}
|
||||
<Text>
|
||||
y accept n reject q reject & quit ESC reject
|
||||
y accept n reject v toggle side-by-side q reject & quit ESC reject
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedView({ hunks }: { hunks: Hunk[] }): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{hunks.map((h, i) => (
|
||||
<HunkLine key={i} hunk={h} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
|||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
{rows.map((row, i) => (
|
||||
<SideBySideRow key={i} left={row[0]} right={row[1]} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SideBySideRow({ left, right }: { left: Hunk | null; right: Hunk | null }): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width="50%">
|
||||
<HalfLine hunk={left} side="left" />
|
||||
</Box>
|
||||
<Text> │ </Text>
|
||||
<Box width="50%">
|
||||
<HalfLine hunk={right} side="right" />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function HalfLine({ hunk, side }: { hunk: Hunk | null; side: 'left' | 'right' }): React.ReactElement {
|
||||
if (!hunk) return <Text> </Text>;
|
||||
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 (
|
||||
<Text color={color}>
|
||||
{sigil} {String(hunk.lineNo).padStart(4, ' ')} {hunk.text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
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<string>): FlatRow[] {
|
||||
function flatten(project: Project, expanded: Set<string>, 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<Set<string>>(new Set());
|
||||
const [cursorIdx, setCursorIdx] = React.useState(0);
|
||||
const [text, setText] = React.useState<string | null>(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 (
|
||||
<Box flexDirection="column">
|
||||
<Text>─ {project.rootDir.split(/[/\\]/).pop()} ─</Text>
|
||||
<Text>
|
||||
─ {project.rootDir.split(/[/\\]/).pop()} ─{stale ? ` mirror ${stale} old ` : ' '}─
|
||||
</Text>
|
||||
<ResizeWarning columns={columns} rows={termRows} />
|
||||
{helpOpen && <HelpOverlay />}
|
||||
{crossDiff && (
|
||||
<CrossDeviceDiff
|
||||
leftLabel={crossDiff.leftLabel}
|
||||
rightLabel={crossDiff.rightLabel}
|
||||
leftText={crossDiff.leftText}
|
||||
rightText={crossDiff.rightText}
|
||||
/>
|
||||
)}
|
||||
{crossPicker && <CrossPicker state={crossPicker} />}
|
||||
{(filterMode || filter) && (
|
||||
<Text color={filterMode ? 'cyan' : undefined}>
|
||||
Filter: {filter}{filterMode ? '_' : ''}
|
||||
</Text>
|
||||
)}
|
||||
<Box flexDirection="row">
|
||||
<Box flexDirection="column" width="40%">
|
||||
<Tree project={project} cursorPath={cursor?.path ?? ''} expanded={expanded} />
|
||||
<Tree project={filteredProject} cursorPath={cursor?.path ?? ''} expanded={effectiveExpanded} />
|
||||
</Box>
|
||||
<Box flexDirection="column" width="60%">
|
||||
<Viewer pou={cursor?.pou ?? null} text={text} scrollTop={scrollTop} visibleRows={20} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Text>j/k nav l expand h collapse q quit</Text>
|
||||
<Text>j/k nav l expand h collapse / filter o open d cross-diff r rescan ? help q quit</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CrossPicker({
|
||||
state,
|
||||
}: {
|
||||
state: { pou: POU; candidates: Array<{ device: string; pou: POU }>; cursor: number };
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
<Text bold>Cross-device diff: pick peer for {state.pou.name}</Text>
|
||||
{state.candidates.map((c, i) => (
|
||||
<Text key={c.device} color={i === state.cursor ? 'cyan' : undefined}>
|
||||
{i === state.cursor ? '▶ ' : ' '}
|
||||
{c.device}/{c.pou.name}
|
||||
</Text>
|
||||
))}
|
||||
<Text dimColor>j/k pick Enter open Esc cancel</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
<Text bold>Cross-device diff: {leftLabel} → {rightLabel} (+{adds} −{dels})</Text>
|
||||
{hunks.map((h, i) => {
|
||||
const sigil = h.kind === 'add' ? '+' : h.kind === 'del' ? '-' : ' ';
|
||||
const color = h.kind === 'add' ? 'green' : h.kind === 'del' ? 'red' : undefined;
|
||||
return (
|
||||
<Text key={i} color={color}>
|
||||
{sigil} {String(h.lineNo).padStart(4, ' ')} {h.text}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
<Text dimColor>q / Esc close</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpOverlay(): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
<Text bold>Keybindings</Text>
|
||||
<Text> j / ↓ move cursor down</Text>
|
||||
<Text> k / ↑ move cursor up</Text>
|
||||
<Text> l / → expand device</Text>
|
||||
<Text> h / ← collapse device</Text>
|
||||
<Text> / filter POU list (Enter commits, Esc clears)</Text>
|
||||
<Text> o open highlighted POU in $EDITOR (or VS Code)</Text>
|
||||
<Text> d diff highlighted POU against same-named POU in another device</Text>
|
||||
<Text> r re-scan mcp-mirror/</Text>
|
||||
<Text> ? toggle this help</Text>
|
||||
<Text> Esc close help</Text>
|
||||
<Text> q quit</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
39
src/tui/browser/Statusbar.tsx
Normal file
39
src/tui/browser/Statusbar.tsx
Normal file
|
|
@ -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 <Text color="yellow">Terminal too narrow ({columns} cols, need {MIN_COLUMNS}+)</Text>;
|
||||
}
|
||||
if (rows < MIN_ROWS) {
|
||||
return <Text color="yellow">Terminal too short ({rows} rows, need {MIN_ROWS}+)</Text>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -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<TokenKind, string | undefined> = {
|
||||
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 (
|
||||
<Text>
|
||||
{tokens.map((t, i) => (
|
||||
<Text key={i} color={COLORS[t.kind]}>
|
||||
{t.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ViewerProps {
|
||||
pou: POU | null;
|
||||
|
|
@ -26,7 +48,8 @@ export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): Reac
|
|||
</Text>
|
||||
{slice.map((l, i) => (
|
||||
<Text key={scrollTop + i}>
|
||||
{String(scrollTop + i + 1).padStart(4, ' ')} {l}
|
||||
<Text dimColor>{String(scrollTop + i + 1).padStart(4, ' ')} </Text>
|
||||
<HighlightedLine line={l} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
|
|
|||
109
src/tui/browser/highlight.ts
Normal file
109
src/tui/browser/highlight.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<number> {
|
||||
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<number> {
|
|||
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(
|
||||
<Browser
|
||||
project={next}
|
||||
readPou={readPou}
|
||||
writeSelection={onWriteSelection}
|
||||
onQuit={onQuit}
|
||||
onRescan={onRescan}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
/>
|
||||
);
|
||||
} catch (err) {
|
||||
process.stderr.write(`phobiCS-tui: rescan failed: ${(err as Error).message}\n`);
|
||||
}
|
||||
};
|
||||
const app = render(
|
||||
<Browser
|
||||
project={project!}
|
||||
readPou={readPou}
|
||||
writeSelection={onWriteSelection}
|
||||
onQuit={onQuit}
|
||||
onRescan={onRescan}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,6 +61,33 @@ describe('<Approve>', () => {
|
|||
expect(decision).toHaveBeenCalledWith('reject');
|
||||
});
|
||||
|
||||
it('toggles to side-by-side on v and shows both halves', async () => {
|
||||
const { stdin, lastFrame } = render(
|
||||
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={() => {}} />
|
||||
);
|
||||
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(
|
||||
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={() => {}} />
|
||||
);
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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('<Browser>', () => {
|
|||
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<string, string> = {
|
||||
'/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(
|
||||
<Browser
|
||||
project={project}
|
||||
readPou={async (pou) => 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(
|
||||
<Browser
|
||||
project={project}
|
||||
readPou={async () => ''}
|
||||
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(
|
||||
<Browser
|
||||
project={project}
|
||||
readPou={async () => ''}
|
||||
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(
|
||||
<Browser
|
||||
project={project}
|
||||
readPou={async () => ''}
|
||||
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(
|
||||
<Browser
|
||||
project={project}
|
||||
readPou={async () => ''}
|
||||
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(
|
||||
<Browser
|
||||
project={project}
|
||||
readPou={async () => ''}
|
||||
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(
|
||||
<Browser
|
||||
project={project}
|
||||
readPou={async () => ''}
|
||||
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(
|
||||
<Browser
|
||||
project={project}
|
||||
readPou={async () => ''}
|
||||
writeSelection={() => {}}
|
||||
onQuit={() => {}}
|
||||
/>
|
||||
);
|
||||
await flush();
|
||||
stdin.write('?');
|
||||
await flush();
|
||||
expect(lastFrame()).toContain('Keybindings');
|
||||
stdin.write(String.fromCharCode(27));
|
||||
await flush();
|
||||
expect(lastFrame()).not.toContain('Keybindings');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
50
tests/tui/Statusbar.test.tsx
Normal file
50
tests/tui/Statusbar.test.tsx
Normal file
|
|
@ -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('<ResizeWarning>', () => {
|
||||
it('renders nothing when terminal is large enough', () => {
|
||||
const { lastFrame } = render(<ResizeWarning columns={MIN_COLUMNS} rows={MIN_ROWS} />);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('warns when columns are below the minimum', () => {
|
||||
const { lastFrame } = render(<ResizeWarning columns={MIN_COLUMNS - 1} rows={MIN_ROWS} />);
|
||||
expect(lastFrame()).toMatch(/Terminal too narrow/);
|
||||
});
|
||||
|
||||
it('warns when rows are below the minimum', () => {
|
||||
const { lastFrame } = render(<ResizeWarning columns={MIN_COLUMNS} rows={MIN_ROWS - 1} />);
|
||||
expect(lastFrame()).toMatch(/Terminal too short/);
|
||||
});
|
||||
});
|
||||
57
tests/tui/highlight.test.ts
Normal file
57
tests/tui/highlight.test.ts
Normal file
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue