tui: add atomic Selection JSON writer
writeSelection() emits the v1 state envelope (version, updated_at,
project_dir, device, selection{kind,name,path,abs_path}, viewer_line)
to <file>.<pid>.tmp then atomic-renames into place. mkdir -p the
parent, no .tmp left behind on success.
This commit is contained in:
parent
461bd10a73
commit
ba40f10e6e
2 changed files with 87 additions and 0 deletions
29
src/tui/shared/state-write.ts
Normal file
29
src/tui/shared/state-write.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { Selection } from './types.js';
|
||||
|
||||
export async function writeSelection(
|
||||
filePath: string,
|
||||
projectDir: string,
|
||||
selection: Selection
|
||||
): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
const payload = {
|
||||
version: 1,
|
||||
updated_at: new Date().toISOString(),
|
||||
project_dir: projectDir,
|
||||
device: selection.device,
|
||||
selection: {
|
||||
kind: selection.pou.kind,
|
||||
name: selection.pou.name,
|
||||
path: selection.pou.relPath,
|
||||
abs_path: selection.pou.absPath,
|
||||
},
|
||||
viewer_line: selection.viewerLine,
|
||||
};
|
||||
|
||||
const tmp = `${filePath}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
||||
await fs.rename(tmp, filePath);
|
||||
}
|
||||
58
tests/tui/state-write.test.ts
Normal file
58
tests/tui/state-write.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { writeSelection } from '../../src/tui/shared/state-write.ts';
|
||||
import { Selection } from '../../src/tui/shared/types.ts';
|
||||
|
||||
async function tmpDir(): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), 'phobics-tui-'));
|
||||
}
|
||||
|
||||
const sampleSelection: Selection = {
|
||||
device: 'CodesysRpi',
|
||||
pou: {
|
||||
name: 'FB_Test',
|
||||
kind: 'FB',
|
||||
relPath: 'Plc Logic/Application/FB_Test.st',
|
||||
absPath: '/abs/Plc Logic/Application/FB_Test.st',
|
||||
loc: 87,
|
||||
mtimeMs: 0,
|
||||
},
|
||||
viewerLine: 12,
|
||||
};
|
||||
|
||||
describe('writeSelection', () => {
|
||||
it('writes a JSON file atomically with v1 envelope', async () => {
|
||||
const dir = await tmpDir();
|
||||
const target = path.join(dir, 'tui-state.json');
|
||||
await writeSelection(target, '/abs/project', sampleSelection);
|
||||
|
||||
const text = await fs.readFile(target, 'utf8');
|
||||
const parsed = JSON.parse(text);
|
||||
expect(parsed.version).toBe(1);
|
||||
expect(parsed.project_dir).toBe('/abs/project');
|
||||
expect(parsed.device).toBe('CodesysRpi');
|
||||
expect(parsed.selection.name).toBe('FB_Test');
|
||||
expect(parsed.selection.kind).toBe('FB');
|
||||
expect(parsed.viewer_line).toBe(12);
|
||||
expect(typeof parsed.updated_at).toBe('string');
|
||||
expect(() => new Date(parsed.updated_at)).not.toThrow();
|
||||
});
|
||||
|
||||
it('creates parent directories as needed', async () => {
|
||||
const dir = await tmpDir();
|
||||
const target = path.join(dir, 'a', 'b', 'tui-state.json');
|
||||
await writeSelection(target, '/abs/project', sampleSelection);
|
||||
const stat = await fs.stat(target);
|
||||
expect(stat.isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not leave the .tmp file behind on success', async () => {
|
||||
const dir = await tmpDir();
|
||||
const target = path.join(dir, 'tui-state.json');
|
||||
await writeSelection(target, '/abs/project', sampleSelection);
|
||||
const entries = await fs.readdir(dir);
|
||||
expect(entries.filter((e) => e.endsWith('.tmp'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue