0
0
Fork 0

tui(browser): d opens cross-device diff for highlighted POU

When the cursor is on a POU, d collects all OTHER devices that have
a POU with the same name.

  - 0 peers: no-op (nothing to compare)
  - exactly 1 peer: open the diff overlay immediately
  - 2+ peers: open <CrossPicker> first; user picks with j/k + Enter

The diff overlay uses computeHunks against the full file contents
(not the IMPL_SENTINEL split, since we want to see decl differences
across devices too). Esc/q closes it.
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-28 23:26:41 +02:00
parent 2a743c29fa
commit e6d833ea1a
2 changed files with 181 additions and 5 deletions

View file

@ -1,9 +1,10 @@
import React from 'react';
import { Box, Text, useInput, useStdout } from 'ink';
import { Project, POU, Selection } from '../shared/types.js';
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;
@ -47,6 +48,17 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on
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 filteredProject = React.useMemo(() => {
if (!filter) return project;
@ -94,7 +106,73 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on
};
}, [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('');
@ -137,6 +215,10 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on
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) {
@ -172,6 +254,15 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on
</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 ? '_' : ''}
@ -185,7 +276,57 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on
<Viewer pou={cursor?.pou ?? null} text={text} scrollTop={scrollTop} visibleRows={20} />
</Box>
</Box>
<Text>j/k nav l expand h collapse / filter o open r rescan ? help 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>
);
}
@ -200,6 +341,7 @@ function HelpOverlay(): React.ReactElement {
<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>

View file

@ -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 },
],
},
],
@ -80,6 +86,34 @@ describe('<Browser>', () => {
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
@ -162,7 +196,7 @@ describe('<Browser>', () => {
await flush();
stdin.write('o');
await flush();
expect(onOpenInEditor).toHaveBeenCalledWith('/abs/PLC_PRG.st');
expect(onOpenInEditor).toHaveBeenCalledWith('/abs/D1/PLC_PRG.st');
});
it('does not call onOpenInEditor when cursor is on a device row', async () => {