Merge pull request #6 from phobicdotno/feature/phobics-tui-v0.3-live-values
phobiCS-tui v0.3: inline live values (server pump + TUI overlay)
This commit is contained in:
commit
a3caf0dd38
18 changed files with 860 additions and 16 deletions
|
|
@ -307,6 +307,15 @@ The Viewer applies ST syntax highlighting (cyan keywords, magenta types, gray co
|
|||
|
||||
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.
|
||||
|
||||
### Inline live values (`--live-values`)
|
||||
|
||||
When the server is started with `--live-values` and the runtime is online, the Viewer overlays each declared variable's live value next to its declaration:
|
||||
|
||||
3 counter : INT := 0; ◀ live: 47
|
||||
4 bRunning : BOOL; ◀ live: TRUE
|
||||
|
||||
The server writes a 500 ms snapshot to `tui-live-values.json` (next to `tui-state.json`); the TUI polls that file and renders an overlay only when the snapshot's `pou_name` matches the user's current selection and the file is fresh (≤ 5 s). v0.3 covers top-level vars on the displayed POU; sub-property paths and ARRAY/STRUCT pretty-printing are deferred. Off by default.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
41 tools across the categories below. Tools marked **NEW** were added in this fork; tools marked **FIXED** existed upstream but were broken before this fork.
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ program
|
|||
.option('--keep-alive', 'Keep CODESYS running after server stops', false)
|
||||
.option('--auto-mirror', 'Re-run mirror_export after every modifying tool so an external editor watching <projectDir>/mcp-mirror/ sees changes live', false)
|
||||
.option('--approve-edits', 'Gate modifying MCP tools behind a phobiCS-tui y/n diff prompt', false)
|
||||
.option('--live-values', 'Pump runtime values for the selected POU into tui-live-values.json so phobiCS-tui can overlay them inline (500ms poll). Requires the runtime to be online; failures are silent.', false)
|
||||
.option('--timeout <ms>', 'Default command timeout in ms', '60000')
|
||||
.option('--verbose', 'Enable verbose logging')
|
||||
.option('--debug', 'Enable debug logging (more verbose)')
|
||||
|
|
@ -199,6 +200,7 @@ if (opts.sshVersion) {
|
|||
mode: (opts.mode === 'headless' ? 'headless' : 'persistent') as ExecutionMode,
|
||||
autoMirror: opts.autoMirror || false,
|
||||
approveEdits: opts.approveEdits || false,
|
||||
liveValues: opts.liveValues || false,
|
||||
};
|
||||
|
||||
process.stderr.write(`Starting CODESYS MCP Server v${version}\n`);
|
||||
|
|
@ -212,6 +214,9 @@ if (opts.sshVersion) {
|
|||
if (config.approveEdits) {
|
||||
process.stderr.write(` Approve edits: ENABLED (modifying tools will prompt via phobiCS-tui)\n`);
|
||||
}
|
||||
if (config.liveValues) {
|
||||
process.stderr.write(` Live values: ENABLED (poll 500ms; writes tui-live-values.json)\n`);
|
||||
}
|
||||
|
||||
startMcpServer(config).catch((err) => {
|
||||
process.stderr.write(`FATAL: ${err.message}\n`);
|
||||
|
|
|
|||
153
src/live-values-pump.ts
Normal file
153
src/live-values-pump.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import { writeLiveValues, LiveValueSnapshotIn } from './live-values-write';
|
||||
import { readSelection } from './state-read';
|
||||
|
||||
const VAR_OPEN_RE = /^\s*(?:VAR(?:_INPUT|_OUTPUT|_IN_OUT|_GLOBAL|_TEMP|_CONFIG|_EXTERNAL|_STAT)?)\b/i;
|
||||
const VAR_CLOSE_RE = /^\s*END_VAR\b/i;
|
||||
const VAR_DECL_RE = /^\s*([A-Za-z_]\w*)\b/;
|
||||
|
||||
/**
|
||||
* Parse all variable names declared in any VAR / VAR_INPUT / VAR_OUTPUT /
|
||||
* VAR_GLOBAL / etc. block. One name per declaration line; skips lines that
|
||||
* are entirely inside (* ... *) blocks (joined across lines) or are
|
||||
* `// ...` comments.
|
||||
*
|
||||
* Recognises the IEC declaration grammar enough for typical PLC code:
|
||||
* `name [AT %loc] : type [:= init];`. Doesn't try to parse multi-name
|
||||
* shorthand (`a, b : INT;`) — that's vanishingly rare in practice and
|
||||
* the cost of getting it wrong is just a missing overlay.
|
||||
*/
|
||||
export function parseVarNames(text: string): string[] {
|
||||
const names: string[] = [];
|
||||
const lines = text.split(/\r?\n/);
|
||||
let inVarBlock = false;
|
||||
let inMultilineComment = false;
|
||||
|
||||
for (let raw of lines) {
|
||||
// Strip block comments first, threading the open-state across lines.
|
||||
let scrubbed = '';
|
||||
let i = 0;
|
||||
while (i < raw.length) {
|
||||
if (inMultilineComment) {
|
||||
const end = raw.indexOf('*)', i);
|
||||
if (end < 0) {
|
||||
i = raw.length;
|
||||
} else {
|
||||
inMultilineComment = false;
|
||||
i = end + 2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (raw[i] === '(' && raw[i + 1] === '*') {
|
||||
const end = raw.indexOf('*)', i + 2);
|
||||
if (end < 0) {
|
||||
inMultilineComment = true;
|
||||
i = raw.length;
|
||||
} else {
|
||||
i = end + 2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
scrubbed += raw[i];
|
||||
i++;
|
||||
}
|
||||
// Strip line comments.
|
||||
const slash = scrubbed.indexOf('//');
|
||||
if (slash >= 0) scrubbed = scrubbed.slice(0, slash);
|
||||
|
||||
if (!inVarBlock) {
|
||||
if (VAR_OPEN_RE.test(scrubbed)) inVarBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (VAR_CLOSE_RE.test(scrubbed)) {
|
||||
inVarBlock = false;
|
||||
continue;
|
||||
}
|
||||
const m = VAR_DECL_RE.exec(scrubbed);
|
||||
if (m) names.push(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// ─── Pump ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The full pump (interval + per-var read_variable round-trips) is wired
|
||||
// up by Task 7 once the server-side plumbing is in place. parseVarNames
|
||||
// is the only piece we can unit-test in isolation; the rest needs an
|
||||
// executor + state-read + write integration.
|
||||
|
||||
export interface PumpDeps {
|
||||
/** Read the TUI selection state file. Defaults to readSelection. */
|
||||
readSelection?: typeof readSelection;
|
||||
/** Read the POU's mirror file from disk. */
|
||||
readPouFile?: (absPath: string) => Promise<string>;
|
||||
/** Read a single PLC variable. Returns its current value as a string. */
|
||||
readVariable?: (projectFilePath: string, variablePath: string) => Promise<string>;
|
||||
/** Write the live-values snapshot. Defaults to writeLiveValues. */
|
||||
writeLiveValues?: typeof writeLiveValues;
|
||||
}
|
||||
|
||||
export interface PumpConfig {
|
||||
/** Path to tui-state.json. */
|
||||
stateFilePath: string;
|
||||
/** Path to tui-live-values.json. */
|
||||
liveValuesFilePath: string;
|
||||
/** Poll interval in ms. */
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
export class LiveValuesPump {
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private busy = false;
|
||||
|
||||
constructor(private cfg: PumpConfig, private deps: Required<PumpDeps>) {}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => {
|
||||
void this.tick();
|
||||
}, this.cfg.intervalMs);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async tick(): Promise<void> {
|
||||
if (this.busy) return;
|
||||
this.busy = true;
|
||||
try {
|
||||
const sel = await this.deps.readSelection(this.cfg.stateFilePath);
|
||||
if (sel.status !== 'ok') return;
|
||||
const pou = sel.payload.selection;
|
||||
const text = await this.deps.readPouFile(pou.abs_path);
|
||||
const names = parseVarNames(text);
|
||||
if (names.length === 0) return;
|
||||
const values: Record<string, LiveValueSnapshotIn> = {};
|
||||
const ts = Date.now();
|
||||
for (const name of names) {
|
||||
try {
|
||||
const v = await this.deps.readVariable(
|
||||
sel.payload.project_dir,
|
||||
`${pou.name}.${name}`
|
||||
);
|
||||
values[name] = { value: v, ts };
|
||||
} catch {
|
||||
// single-var failure: skip silently, others may succeed
|
||||
}
|
||||
}
|
||||
await this.deps.writeLiveValues(this.cfg.liveValuesFilePath, sel.payload.project_dir, {
|
||||
device: sel.payload.device,
|
||||
pou_name: pou.name,
|
||||
values,
|
||||
});
|
||||
} catch {
|
||||
// Pump must never crash; swallow.
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/live-values-write.ts
Normal file
41
src/live-values-write.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface LiveValueSnapshotIn {
|
||||
value: string;
|
||||
type?: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export interface LiveValuesPayloadIn {
|
||||
device: string;
|
||||
pou_name: string;
|
||||
values: Record<string, LiveValueSnapshotIn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side counterpart to the TUI's readLiveValues.
|
||||
*
|
||||
* Wraps the caller's payload in the v1 envelope, writes atomically via
|
||||
* `<file>.<pid>.tmp` + rename, and creates parent dirs as needed. Mirrors
|
||||
* src/tui/shared/state-write.ts (the selection writer); kept separate
|
||||
* because that one's ESM and the server is CJS.
|
||||
*/
|
||||
export async function writeLiveValues(
|
||||
filePath: string,
|
||||
projectDir: string,
|
||||
payload: LiveValuesPayloadIn
|
||||
): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const envelope = {
|
||||
version: 1,
|
||||
updated_at: new Date().toISOString(),
|
||||
project_dir: projectDir,
|
||||
device: payload.device,
|
||||
pou_name: payload.pou_name,
|
||||
values: payload.values,
|
||||
};
|
||||
const tmp = `${filePath}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(envelope, null, 2), 'utf8');
|
||||
await fs.rename(tmp, filePath);
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ import { inspectProjectFile } from './inspect';
|
|||
import { parseProfileName } from './detect';
|
||||
import { decideOpenProjectPreflight } from './preflight';
|
||||
import { readSelection } from './state-read';
|
||||
import { writeLiveValues } from './live-values-write';
|
||||
import { LiveValuesPump } from './live-values-pump';
|
||||
import { runApproveGate, gateOpForTool } from './approve-gate';
|
||||
|
||||
/**
|
||||
|
|
@ -707,17 +709,25 @@ export async function buildGetUserSelectionResponse(stateFilePath: string) {
|
|||
};
|
||||
}
|
||||
|
||||
function defaultStateFilePath(): string {
|
||||
function defaultStateDir(): string {
|
||||
if (process.platform === 'win32') {
|
||||
const localAppData = process.env.LOCALAPPDATA;
|
||||
if (!localAppData) {
|
||||
return path.join(os.homedir(), 'AppData', 'Local', 'codesys-mcp', 'tui-state.json');
|
||||
return path.join(os.homedir(), 'AppData', 'Local', 'codesys-mcp');
|
||||
}
|
||||
return path.join(localAppData, 'codesys-mcp', 'tui-state.json');
|
||||
return path.join(localAppData, 'codesys-mcp');
|
||||
}
|
||||
const xdg = process.env.XDG_STATE_HOME;
|
||||
const base = xdg ?? path.join(os.homedir(), '.local', 'state');
|
||||
return path.join(base, 'codesys-mcp', 'tui-state.json');
|
||||
return path.join(base, 'codesys-mcp');
|
||||
}
|
||||
|
||||
function defaultStateFilePath(): string {
|
||||
return path.join(defaultStateDir(), 'tui-state.json');
|
||||
}
|
||||
|
||||
function defaultLiveValuesFilePath(): string {
|
||||
return path.join(defaultStateDir(), 'tui-live-values.json');
|
||||
}
|
||||
|
||||
export async function startMcpServer(config: ServerConfig): Promise<void> {
|
||||
|
|
@ -728,6 +738,7 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
|
|||
serverLog.info(`Starting CODESYS Persistent MCP Server v0.1.0`);
|
||||
serverLog.info(`Mode: ${config.mode}`);
|
||||
serverLog.info(`Approve edits: ${config.approveEdits ? 'ON' : 'off'}`);
|
||||
serverLog.info(`Live values: ${config.liveValues ? 'ON' : 'off'}`);
|
||||
serverLog.info(`CODESYS Path: ${config.codesysPath}`);
|
||||
serverLog.info(`Profile: ${config.profileName}`);
|
||||
serverLog.info(`Workspace: ${config.workspaceDir}`);
|
||||
|
|
@ -3140,10 +3151,49 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
|
|||
server.connect(transport);
|
||||
serverLog.info('MCP Server connected and listening.');
|
||||
|
||||
// ─── Live values pump (opt-in) ───────────────────────────────────────
|
||||
|
||||
let liveValuesPump: LiveValuesPump | null = null;
|
||||
if (config.liveValues) {
|
||||
liveValuesPump = new LiveValuesPump(
|
||||
{
|
||||
stateFilePath: defaultStateFilePath(),
|
||||
liveValuesFilePath: defaultLiveValuesFilePath(),
|
||||
intervalMs: 500,
|
||||
},
|
||||
{
|
||||
readSelection,
|
||||
readPouFile: (absPath) => fs.promises.readFile(absPath, 'utf8'),
|
||||
readVariable: async (projectFilePath, variablePath) => {
|
||||
// Reuse the existing read_variable script. Returns the value as
|
||||
// a string captured from script stdout. Errors throw.
|
||||
const script = scriptManager.prepareScriptWithHelpers(
|
||||
'read_variable',
|
||||
{ PROJECT_FILE_PATH: projectFilePath, VARIABLE_PATH: variablePath },
|
||||
['ensure_project_open', 'ensure_online_connection']
|
||||
);
|
||||
const result = await executor.executeScript(script);
|
||||
if (!result.success || !result.output.includes('SCRIPT_SUCCESS')) {
|
||||
throw new Error(result.error || 'read_variable failed');
|
||||
}
|
||||
// The script prints `Value: <value>` on the success line.
|
||||
const m = /Value:\s*(.*)$/m.exec(result.output);
|
||||
return m ? m[1].trim() : '';
|
||||
},
|
||||
writeLiveValues,
|
||||
}
|
||||
);
|
||||
liveValuesPump.start();
|
||||
serverLog.info('Live-values pump started (500ms)');
|
||||
}
|
||||
|
||||
// ─── Graceful Shutdown ───────────────────────────────────────────────
|
||||
|
||||
const shutdown = async () => {
|
||||
serverLog.info('Shutdown signal received');
|
||||
if (liveValuesPump) {
|
||||
liveValuesPump.stop();
|
||||
}
|
||||
if (launcher) {
|
||||
try {
|
||||
await launcher.shutdown();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import { Box, Text } from 'ink';
|
||||
import { POU } from '../shared/types.js';
|
||||
import { Token, tokenizeLines, TokenKind } from './highlight.js';
|
||||
import { LiveValueSnapshot } from '../shared/live-values.js';
|
||||
|
||||
const COLORS: Record<TokenKind, string | undefined> = {
|
||||
keyword: 'cyan',
|
||||
|
|
@ -23,14 +24,38 @@ function HighlightedTokens({ tokens }: { tokens: Token[] }): React.ReactElement
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first var name on the line that has a live value.
|
||||
*
|
||||
* Match rule: scan the raw line for identifiers, return the value of the
|
||||
* first one that's a key in liveValues. Operates on the original line text
|
||||
* (not on highlighter tokens) because the highlighter's uppercase-only
|
||||
* identifier splitter shreds mixed-case names like `bRunning` into
|
||||
* `b` + `R` + `unning`, which would never match a `bRunning` key.
|
||||
*/
|
||||
function findOverlayValue(
|
||||
line: string,
|
||||
liveValues: Record<string, LiveValueSnapshot>
|
||||
): string | null {
|
||||
const re = /[A-Za-z_][A-Za-z0-9_]*/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
const v = liveValues[m[0]];
|
||||
if (v) return v.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ViewerProps {
|
||||
pou: POU | null;
|
||||
text: string | null;
|
||||
scrollTop: number;
|
||||
visibleRows: number;
|
||||
/** Optional bare-name → live-value map. When set, lines with a matching var get an inline overlay. */
|
||||
liveValues?: Record<string, LiveValueSnapshot>;
|
||||
}
|
||||
|
||||
export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): React.ReactElement {
|
||||
export function Viewer({ pou, text, scrollTop, visibleRows, liveValues }: ViewerProps): React.ReactElement {
|
||||
if (!pou || text == null) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
|
|
@ -48,12 +73,17 @@ export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): Reac
|
|||
<Text bold>
|
||||
{pou.name}.st ({pou.kind}, {pou.loc} L)
|
||||
</Text>
|
||||
{sliceTokens.map((tokens, i) => (
|
||||
<Text key={scrollTop + i}>
|
||||
<Text dimColor>{String(scrollTop + i + 1).padStart(4, ' ')} </Text>
|
||||
<HighlightedTokens tokens={tokens} />
|
||||
</Text>
|
||||
))}
|
||||
{sliceTokens.map((tokens, i) => {
|
||||
const lineNo = scrollTop + i;
|
||||
const overlay = liveValues ? findOverlayValue(lines[lineNo] ?? '', liveValues) : null;
|
||||
return (
|
||||
<Text key={lineNo}>
|
||||
<Text dimColor>{String(lineNo + 1).padStart(4, ' ')} </Text>
|
||||
<HighlightedTokens tokens={tokens} />
|
||||
{overlay !== null && <Text color="green"> ◀ live: {overlay}</Text>}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
47
src/tui/browser/useLiveValues.tsx
Normal file
47
src/tui/browser/useLiveValues.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
import { LiveValueSnapshot } from '../shared/live-values.js';
|
||||
import { readLiveValues } from '../shared/live-values-read.js';
|
||||
|
||||
/**
|
||||
* Poll the live-values file every `intervalMs` and return the values map
|
||||
* iff the snapshot's pou_name matches the requested `pouName`. Returns
|
||||
* null when:
|
||||
* - pouName is null (cursor not on a POU)
|
||||
* - file is missing / stale / invalid
|
||||
* - snapshot's pou_name disagrees with the requested pouName
|
||||
*/
|
||||
export function useLiveValues(
|
||||
filePath: string | null,
|
||||
pouName: string | null,
|
||||
intervalMs = 500
|
||||
): Record<string, LiveValueSnapshot> | null {
|
||||
const [values, setValues] = React.useState<Record<string, LiveValueSnapshot> | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!filePath || !pouName) {
|
||||
setValues(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const tick = async () => {
|
||||
const r = await readLiveValues(filePath);
|
||||
if (cancelled) return;
|
||||
if (r.status === 'ok' && r.payload.pou_name === pouName) {
|
||||
setValues(r.payload.values);
|
||||
} else {
|
||||
setValues(null);
|
||||
}
|
||||
timer = setTimeout(tick, intervalMs);
|
||||
};
|
||||
tick();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [filePath, pouName, intervalMs]);
|
||||
|
||||
return values;
|
||||
}
|
||||
38
src/tui/shared/live-values-read.ts
Normal file
38
src/tui/shared/live-values-read.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import { LiveValuesPayload } from './live-values.js';
|
||||
|
||||
/**
|
||||
* Live values stop being interesting fast — tighter than the selection
|
||||
* file's 60s window. If the pump dies or the runtime goes offline, we'd
|
||||
* rather show no overlay than a frozen value.
|
||||
*/
|
||||
export const FRESHNESS_MS = 5_000;
|
||||
|
||||
export type ReadResult =
|
||||
| { status: 'ok'; payload: LiveValuesPayload }
|
||||
| { status: 'missing' }
|
||||
| { status: 'stale' }
|
||||
| { status: 'invalid'; reason: string };
|
||||
|
||||
export async function readLiveValues(filePath: string): Promise<ReadResult> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await fs.readFile(filePath, 'utf8');
|
||||
} catch {
|
||||
return { status: 'missing' };
|
||||
}
|
||||
let parsed: LiveValuesPayload;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (err) {
|
||||
return { status: 'invalid', reason: (err as Error).message };
|
||||
}
|
||||
if (parsed.version !== 1) {
|
||||
return { status: 'invalid', reason: `unsupported version ${parsed.version}` };
|
||||
}
|
||||
const ageMs = Date.now() - new Date(parsed.updated_at).getTime();
|
||||
if (Number.isNaN(ageMs) || ageMs > FRESHNESS_MS) {
|
||||
return { status: 'stale' };
|
||||
}
|
||||
return { status: 'ok', payload: parsed };
|
||||
}
|
||||
31
src/tui/shared/live-values.ts
Normal file
31
src/tui/shared/live-values.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Shape of `tui-live-values.json` written by the server's live-values pump.
|
||||
* The TUI Viewer reads this to overlay live runtime values inline next to
|
||||
* declared variable names.
|
||||
*
|
||||
* One file per machine; keyed by which POU the user is currently looking at
|
||||
* in `tui-state.json` so the TUI knows whether the values match its current
|
||||
* view and can ignore stale snapshots from a previous selection.
|
||||
*/
|
||||
export interface LiveValuesPayload {
|
||||
version: 1;
|
||||
/** ISO 8601 timestamp of the moment the snapshot was written. */
|
||||
updated_at: string;
|
||||
/** Project root dir the runtime was queried for. Matches the project_dir field in tui-state.json. */
|
||||
project_dir: string;
|
||||
/** Device name the runtime belongs to. Matches the device field in tui-state.json. */
|
||||
device: string;
|
||||
/** POU the values were read from. Bare name (no path); matches tui-state.json's selection.name. */
|
||||
pou_name: string;
|
||||
/** Variable name -> value snapshot. Bare names (no `PLC_PRG.` prefix). */
|
||||
values: Record<string, LiveValueSnapshot>;
|
||||
}
|
||||
|
||||
export interface LiveValueSnapshot {
|
||||
/** Live value as a string (whatever read_variable returned). */
|
||||
value: string;
|
||||
/** Optional declared type, populated when the pump can determine it. */
|
||||
type?: string;
|
||||
/** Per-value timestamp in ms since epoch. Useful when the snapshot was assembled across several reads. */
|
||||
ts: number;
|
||||
}
|
||||
|
|
@ -2,18 +2,31 @@ import * as os from 'os';
|
|||
import * as path from 'path';
|
||||
|
||||
const APP_DIR = 'codesys-mcp';
|
||||
const FILE_NAME = 'tui-state.json';
|
||||
const STATE_FILE = 'tui-state.json';
|
||||
const LIVE_VALUES_FILE = 'tui-live-values.json';
|
||||
|
||||
export function stateFilePath(): string {
|
||||
function resolveStateDir(): string {
|
||||
if (process.platform === 'win32') {
|
||||
const localAppData = process.env.LOCALAPPDATA;
|
||||
if (!localAppData) {
|
||||
throw new Error('LOCALAPPDATA is not set; cannot resolve TUI state file path');
|
||||
}
|
||||
return path.win32.join(localAppData, APP_DIR, FILE_NAME);
|
||||
return path.win32.join(localAppData, APP_DIR);
|
||||
}
|
||||
const xdg = process.env.XDG_STATE_HOME;
|
||||
const home = process.env.HOME ?? os.homedir();
|
||||
const base = xdg ?? path.posix.join(home, '.local', 'state');
|
||||
return path.posix.join(base, APP_DIR, FILE_NAME);
|
||||
return path.posix.join(base, APP_DIR);
|
||||
}
|
||||
|
||||
function joinForPlatform(dir: string, file: string): string {
|
||||
return process.platform === 'win32' ? path.win32.join(dir, file) : path.posix.join(dir, file);
|
||||
}
|
||||
|
||||
export function stateFilePath(): string {
|
||||
return joinForPlatform(resolveStateDir(), STATE_FILE);
|
||||
}
|
||||
|
||||
export function liveValuesFilePath(): string {
|
||||
return joinForPlatform(resolveStateDir(), LIVE_VALUES_FILE);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,13 @@ export interface ServerConfig extends LauncherConfig {
|
|||
* so existing scripted flows are not regressed.
|
||||
*/
|
||||
approveEdits?: boolean;
|
||||
/**
|
||||
* If true, run a background pump that reads runtime values for the
|
||||
* variables of the user's currently-selected POU and writes them to
|
||||
* tui-live-values.json so the TUI Viewer can overlay them inline.
|
||||
* Off by default. 500 ms interval (fixed in v0.3).
|
||||
*/
|
||||
liveValues?: boolean;
|
||||
}
|
||||
|
||||
/** Script template parameters */
|
||||
|
|
|
|||
66
tests/tui/Viewer.test.tsx
Normal file
66
tests/tui/Viewer.test.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { Viewer } from '../../src/tui/browser/Viewer.tsx';
|
||||
import { POU } from '../../src/tui/shared/types.ts';
|
||||
|
||||
const pou: POU = {
|
||||
name: 'PLC_PRG',
|
||||
kind: 'PRG',
|
||||
relPath: 'PLC_PRG.st',
|
||||
absPath: '/abs/PLC_PRG.st',
|
||||
loc: 4,
|
||||
mtimeMs: 0,
|
||||
};
|
||||
|
||||
const text = [
|
||||
'PROGRAM PLC_PRG',
|
||||
'VAR',
|
||||
' counter : INT := 0;',
|
||||
' bRunning : BOOL;',
|
||||
'END_VAR',
|
||||
].join('\n');
|
||||
|
||||
describe('<Viewer>', () => {
|
||||
it('renders without overlay when liveValues is absent', () => {
|
||||
const { lastFrame } = render(
|
||||
<Viewer pou={pou} text={text} scrollTop={0} visibleRows={10} />
|
||||
);
|
||||
expect(lastFrame()).not.toContain('live:');
|
||||
});
|
||||
|
||||
it('overlays "◀ live: <val>" on the line whose token matches a key', () => {
|
||||
const { lastFrame } = render(
|
||||
<Viewer
|
||||
pou={pou}
|
||||
text={text}
|
||||
scrollTop={0}
|
||||
visibleRows={10}
|
||||
liveValues={{
|
||||
counter: { value: '47', type: 'INT', ts: Date.now() },
|
||||
bRunning: { value: 'TRUE', type: 'BOOL', ts: Date.now() },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const out = lastFrame()!;
|
||||
expect(out).toMatch(/counter\b.*◀ live: 47/);
|
||||
expect(out).toMatch(/bRunning\b.*◀ live: TRUE/);
|
||||
});
|
||||
|
||||
it('does not overlay vars that are not in liveValues', () => {
|
||||
const { lastFrame } = render(
|
||||
<Viewer
|
||||
pou={pou}
|
||||
text={text}
|
||||
scrollTop={0}
|
||||
visibleRows={10}
|
||||
liveValues={{ counter: { value: '47', ts: Date.now() } }}
|
||||
/>
|
||||
);
|
||||
const out = lastFrame()!;
|
||||
expect(out).toMatch(/counter\b.*◀ live: 47/);
|
||||
// bRunning line must not gain an overlay
|
||||
const bRunningLine = out.split('\n').find((l) => l.includes('bRunning'))!;
|
||||
expect(bRunningLine).not.toContain('live:');
|
||||
});
|
||||
});
|
||||
55
tests/tui/live-values-read.test.ts
Normal file
55
tests/tui/live-values-read.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { readLiveValues, FRESHNESS_MS } from '../../src/tui/shared/live-values-read.ts';
|
||||
|
||||
async function tmpFile(content: string): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'phobics-lv-'));
|
||||
const f = path.join(dir, 'tui-live-values.json');
|
||||
await fs.writeFile(f, content, 'utf8');
|
||||
return f;
|
||||
}
|
||||
|
||||
const fresh = (overrides: Record<string, unknown> = {}) =>
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updated_at: new Date().toISOString(),
|
||||
project_dir: '/p',
|
||||
device: 'D1',
|
||||
pou_name: 'PLC_PRG',
|
||||
values: {
|
||||
counter: { value: '47', type: 'INT', ts: Date.now() },
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('readLiveValues', () => {
|
||||
it('returns ok with the parsed payload when fresh', async () => {
|
||||
const f = await tmpFile(fresh());
|
||||
const r = await readLiveValues(f);
|
||||
expect(r.status).toBe('ok');
|
||||
if (r.status === 'ok') {
|
||||
expect(r.payload.pou_name).toBe('PLC_PRG');
|
||||
expect(r.payload.values['counter'].value).toBe('47');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns stale when older than FRESHNESS_MS', async () => {
|
||||
const old = JSON.stringify({
|
||||
...JSON.parse(fresh()),
|
||||
updated_at: new Date(Date.now() - FRESHNESS_MS - 1000).toISOString(),
|
||||
});
|
||||
const f = await tmpFile(old);
|
||||
expect((await readLiveValues(f)).status).toBe('stale');
|
||||
});
|
||||
|
||||
it('returns missing when file does not exist', async () => {
|
||||
expect((await readLiveValues('/nonexistent.json')).status).toBe('missing');
|
||||
});
|
||||
|
||||
it('returns invalid for malformed JSON', async () => {
|
||||
const f = await tmpFile('not json');
|
||||
expect((await readLiveValues(f)).status).toBe('invalid');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as path from 'path';
|
||||
import { stateFilePath } from '../../src/tui/shared/state-paths.ts';
|
||||
import { stateFilePath, liveValuesFilePath } from '../../src/tui/shared/state-paths.ts';
|
||||
|
||||
const ORIG_PLATFORM = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
const ORIG_ENV = { ...process.env };
|
||||
|
|
@ -49,3 +49,33 @@ describe('stateFilePath', () => {
|
|||
expect(() => stateFilePath()).toThrow(/LOCALAPPDATA/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('liveValuesFilePath', () => {
|
||||
it('uses %LOCALAPPDATA%/codesys-mcp/tui-live-values.json on Windows', () => {
|
||||
setPlatform('win32');
|
||||
process.env.LOCALAPPDATA = 'C:\\\\Users\\\\u\\\\AppData\\\\Local';
|
||||
const p = liveValuesFilePath();
|
||||
expect(p).toBe(
|
||||
path.join('C:\\\\Users\\\\u\\\\AppData\\\\Local', 'codesys-mcp', 'tui-live-values.json')
|
||||
);
|
||||
});
|
||||
|
||||
it('uses $XDG_STATE_HOME/codesys-mcp/tui-live-values.json when set', () => {
|
||||
setPlatform('linux');
|
||||
process.env.XDG_STATE_HOME = '/tmp/xdg-state';
|
||||
expect(liveValuesFilePath()).toBe('/tmp/xdg-state/codesys-mcp/tui-live-values.json');
|
||||
});
|
||||
|
||||
it('falls back to ~/.local/state on Linux without XDG_STATE_HOME', () => {
|
||||
setPlatform('linux');
|
||||
delete process.env.XDG_STATE_HOME;
|
||||
process.env.HOME = '/home/u';
|
||||
expect(liveValuesFilePath()).toBe('/home/u/.local/state/codesys-mcp/tui-live-values.json');
|
||||
});
|
||||
|
||||
it('throws on Windows when LOCALAPPDATA is unset', () => {
|
||||
setPlatform('win32');
|
||||
delete process.env.LOCALAPPDATA;
|
||||
expect(() => liveValuesFilePath()).toThrow(/LOCALAPPDATA/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
58
tests/tui/useLiveValues.test.tsx
Normal file
58
tests/tui/useLiveValues.test.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { Text } from 'ink';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { useLiveValues } from '../../src/tui/browser/useLiveValues.tsx';
|
||||
|
||||
async function tmpFile(content: string): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'phobics-uv-'));
|
||||
const f = path.join(dir, 'tui-live-values.json');
|
||||
await fs.writeFile(f, content, 'utf8');
|
||||
return f;
|
||||
}
|
||||
|
||||
const fresh = (pouName = 'PLC_PRG') =>
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updated_at: new Date().toISOString(),
|
||||
project_dir: '/p',
|
||||
device: 'D1',
|
||||
pou_name: pouName,
|
||||
values: { counter: { value: '47', ts: Date.now() } },
|
||||
});
|
||||
|
||||
function Probe({ filePath, pouName }: { filePath: string; pouName: string | null }) {
|
||||
const lv = useLiveValues(filePath, pouName, 50);
|
||||
return <Text>{lv ? `MATCH:${Object.keys(lv).join(',')}` : 'NULL'}</Text>;
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
describe('useLiveValues', () => {
|
||||
it('returns map when pou_name matches', async () => {
|
||||
const f = await tmpFile(fresh('PLC_PRG'));
|
||||
const { lastFrame, unmount } = render(<Probe filePath={f} pouName="PLC_PRG" />);
|
||||
await wait(120);
|
||||
expect(lastFrame()).toBe('MATCH:counter');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('returns null when pou_name does not match', async () => {
|
||||
const f = await tmpFile(fresh('FB_Other'));
|
||||
const { lastFrame, unmount } = render(<Probe filePath={f} pouName="PLC_PRG" />);
|
||||
await wait(120);
|
||||
expect(lastFrame()).toBe('NULL');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('returns null when pouName is null (cursor not on a POU)', async () => {
|
||||
const f = await tmpFile(fresh('PLC_PRG'));
|
||||
const { lastFrame, unmount } = render(<Probe filePath={f} pouName={null} />);
|
||||
await wait(120);
|
||||
expect(lastFrame()).toBe('NULL');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
106
tests/unit/live-values-pump.test.ts
Normal file
106
tests/unit/live-values-pump.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { LiveValuesPump } from '../../src/live-values-pump';
|
||||
|
||||
const okSelection = (overrides: Record<string, unknown> = {}) => ({
|
||||
status: 'ok' as const,
|
||||
payload: {
|
||||
version: 1 as const,
|
||||
updated_at: new Date().toISOString(),
|
||||
project_dir: '/abs/proj',
|
||||
device: 'D1',
|
||||
selection: {
|
||||
kind: 'PRG',
|
||||
name: 'PLC_PRG',
|
||||
path: 'PLC_PRG.st',
|
||||
abs_path: '/abs/PLC_PRG.st',
|
||||
},
|
||||
viewer_line: 1,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
const sampleSt = [
|
||||
'PROGRAM PLC_PRG',
|
||||
'VAR',
|
||||
' counter : INT := 0;',
|
||||
' bRunning : BOOL;',
|
||||
'END_VAR',
|
||||
].join('\n');
|
||||
|
||||
describe('LiveValuesPump.tick', () => {
|
||||
it('writes a snapshot containing every var read successfully', async () => {
|
||||
const writeLiveValues = vi.fn(async () => {});
|
||||
const readVariable = vi.fn(async (_proj: string, varPath: string) => {
|
||||
if (varPath.endsWith('.counter')) return '47';
|
||||
if (varPath.endsWith('.bRunning')) return 'TRUE';
|
||||
throw new Error('not found');
|
||||
});
|
||||
const pump = new LiveValuesPump(
|
||||
{ stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 },
|
||||
{
|
||||
readSelection: vi.fn(async () => okSelection()),
|
||||
readPouFile: vi.fn(async () => sampleSt),
|
||||
readVariable,
|
||||
writeLiveValues,
|
||||
}
|
||||
);
|
||||
await pump.tick();
|
||||
expect(writeLiveValues).toHaveBeenCalledTimes(1);
|
||||
const [, projectDir, payload] = writeLiveValues.mock.calls[0];
|
||||
expect(projectDir).toBe('/abs/proj');
|
||||
expect(payload.device).toBe('D1');
|
||||
expect(payload.pou_name).toBe('PLC_PRG');
|
||||
expect(payload.values.counter.value).toBe('47');
|
||||
expect(payload.values.bRunning.value).toBe('TRUE');
|
||||
});
|
||||
|
||||
it('skips writing when selection is missing/stale', async () => {
|
||||
const writeLiveValues = vi.fn(async () => {});
|
||||
const pump = new LiveValuesPump(
|
||||
{ stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 },
|
||||
{
|
||||
readSelection: vi.fn(async () => ({ status: 'missing' as const })),
|
||||
readPouFile: vi.fn(async () => sampleSt),
|
||||
readVariable: vi.fn(async () => '1'),
|
||||
writeLiveValues,
|
||||
}
|
||||
);
|
||||
await pump.tick();
|
||||
expect(writeLiveValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('continues when one read_variable fails (partial write)', async () => {
|
||||
const writeLiveValues = vi.fn(async () => {});
|
||||
const pump = new LiveValuesPump(
|
||||
{ stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 },
|
||||
{
|
||||
readSelection: vi.fn(async () => okSelection()),
|
||||
readPouFile: vi.fn(async () => sampleSt),
|
||||
readVariable: vi.fn(async (_p, varPath) => {
|
||||
if (varPath.endsWith('.counter')) return '47';
|
||||
throw new Error('boom');
|
||||
}),
|
||||
writeLiveValues,
|
||||
}
|
||||
);
|
||||
await pump.tick();
|
||||
const payload = writeLiveValues.mock.calls[0][2];
|
||||
expect(payload.values.counter.value).toBe('47');
|
||||
expect(payload.values.bRunning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never throws when readPouFile throws', async () => {
|
||||
const writeLiveValues = vi.fn(async () => {});
|
||||
const pump = new LiveValuesPump(
|
||||
{ stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 },
|
||||
{
|
||||
readSelection: vi.fn(async () => okSelection()),
|
||||
readPouFile: vi.fn(async () => { throw new Error('disk gone'); }),
|
||||
readVariable: vi.fn(async () => '1'),
|
||||
writeLiveValues,
|
||||
}
|
||||
);
|
||||
await expect(pump.tick()).resolves.toBeUndefined();
|
||||
expect(writeLiveValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
48
tests/unit/live-values-write.test.ts
Normal file
48
tests/unit/live-values-write.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { writeLiveValues } from '../../src/live-values-write';
|
||||
|
||||
async function tmpDir(): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), 'phobics-lvw-'));
|
||||
}
|
||||
|
||||
const sample = {
|
||||
device: 'D1',
|
||||
pou_name: 'PLC_PRG',
|
||||
values: {
|
||||
counter: { value: '47', type: 'INT', ts: Date.now() },
|
||||
},
|
||||
};
|
||||
|
||||
describe('writeLiveValues', () => {
|
||||
it('writes the v1 envelope with required fields', async () => {
|
||||
const dir = await tmpDir();
|
||||
const target = path.join(dir, 'tui-live-values.json');
|
||||
await writeLiveValues(target, '/abs/project', sample);
|
||||
|
||||
const parsed = JSON.parse(await fs.readFile(target, 'utf8'));
|
||||
expect(parsed.version).toBe(1);
|
||||
expect(parsed.project_dir).toBe('/abs/project');
|
||||
expect(parsed.device).toBe('D1');
|
||||
expect(parsed.pou_name).toBe('PLC_PRG');
|
||||
expect(parsed.values.counter.value).toBe('47');
|
||||
expect(typeof parsed.updated_at).toBe('string');
|
||||
});
|
||||
|
||||
it('creates parent dirs as needed', async () => {
|
||||
const dir = await tmpDir();
|
||||
const target = path.join(dir, 'a', 'b', 'tui-live-values.json');
|
||||
await writeLiveValues(target, '/abs/project', sample);
|
||||
expect((await fs.stat(target)).isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not leave .tmp residue on success', async () => {
|
||||
const dir = await tmpDir();
|
||||
const target = path.join(dir, 'tui-live-values.json');
|
||||
await writeLiveValues(target, '/abs/project', sample);
|
||||
const entries = await fs.readdir(dir);
|
||||
expect(entries.filter((e) => e.endsWith('.tmp'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
57
tests/unit/var-block-parse.test.ts
Normal file
57
tests/unit/var-block-parse.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseVarNames } from '../../src/live-values-pump';
|
||||
|
||||
describe('parseVarNames', () => {
|
||||
it('extracts vars from a single VAR block', () => {
|
||||
const text = [
|
||||
'PROGRAM PLC_PRG',
|
||||
'VAR',
|
||||
' counter : INT := 0;',
|
||||
' bRunning : BOOL;',
|
||||
' rTemperature : REAL;',
|
||||
'END_VAR',
|
||||
].join('\n');
|
||||
expect(parseVarNames(text)).toEqual(['counter', 'bRunning', 'rTemperature']);
|
||||
});
|
||||
|
||||
it('handles VAR_INPUT / VAR_OUTPUT / VAR_GLOBAL too', () => {
|
||||
const text = [
|
||||
'FUNCTION_BLOCK FB_X',
|
||||
'VAR_INPUT',
|
||||
' x : INT;',
|
||||
'END_VAR',
|
||||
'VAR_OUTPUT',
|
||||
' y : BOOL;',
|
||||
'END_VAR',
|
||||
'VAR',
|
||||
' internal : DINT;',
|
||||
'END_VAR',
|
||||
].join('\n');
|
||||
expect(parseVarNames(text).sort()).toEqual(['internal', 'x', 'y']);
|
||||
});
|
||||
|
||||
it('ignores lines inside (* ... *) blocks and after //', () => {
|
||||
const text = [
|
||||
'VAR',
|
||||
' alpha : INT;',
|
||||
' (* commentedOut : BOOL; *)',
|
||||
' beta : INT; // tail comment',
|
||||
'END_VAR',
|
||||
].join('\n');
|
||||
expect(parseVarNames(text)).toEqual(['alpha', 'beta']);
|
||||
});
|
||||
|
||||
it('returns [] when no VAR block is present', () => {
|
||||
expect(parseVarNames('PROGRAM X\nEND_PROGRAM')).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles AT %X10.0 location prefix and := initializer', () => {
|
||||
const text = [
|
||||
'VAR',
|
||||
' bRelay AT %QX0.1 : BOOL := FALSE;',
|
||||
' iCount : INT := 42;',
|
||||
'END_VAR',
|
||||
].join('\n');
|
||||
expect(parseVarNames(text)).toEqual(['bRelay', 'iCount']);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue