feat(stack-files): force-text override for misidentified binary files (#1215)

* feat(stack-files): force-text override for misidentified binary files

The binary-detection heuristic (30% non-printable / NUL in the first
8 KB) sometimes flags UTF-8 files that happen to carry an embedded NUL
or a high non-printable ratio, locking the user out of inline editing
with only a Download fallback.

readStackFile now accepts an optional { forceText: true } that bypasses
isBinaryBuffer on the small-file path and returns the bytes as UTF-8
content. The route exposes this as ?force=text on GET /files/content.
The oversized branch deliberately stays untouched: returning a multi-MB
file as JSON-encoded text is wasteful regardless of the heuristic.

SpecialFilePanel grows an optional extraAction slot. The viewer's
binary branch wires Open as text anyway, which refetches with the new
flag, clears isBinary, and routes the content through the existing
Monaco editor path. A failed override surfaces both an inline error
panel and a toast so the user knows why the click did nothing.

Backend tests pin the heuristic-vs-override behaviour pair on a file
with a literal NUL byte. The frontend test asserts that the second
readStackFile call carries forceText: true and that Monaco mounts.
Troubleshooting accordion entry updated to mention the new affordance.

* fix(stack-files): guard the binary-override path against oversized files

The override on the binary panel could open Monaco against an empty
content buffer if the backend's oversized branch ran (files past the
2 MB inline-preview cap intentionally carry no content even when
force=text is set). Saving that empty buffer would wipe the file on
disk.

Two reinforcing changes:

- Initial load now checks result.oversized before result.binary, so a
  file that is both oversized and has binary bytes in the 8 KB probe
  shows the Download panel rather than the binary panel. The size
  signal stays in front of the operator and the override button never
  surfaces for a file that cannot be safely opened inline.

- The handleForceText handler now respects result.oversized on the
  refetch and transitions to the Download panel instead of clearing
  isBinary and copying result.content ?? '' into Monaco.

Same handler also gains a stale-request guard via a selectedPathRef:
a slow override for file A no longer stomps on file B's state if the
user navigated away while the request was in flight.

Two regression tests pin the new behaviour: oversized+binary surfaces
the Download panel on initial load, and an oversized refetch from the
binary panel routes to the Download panel rather than Monaco.
This commit is contained in:
Anso
2026-05-25 01:38:04 -04:00
committed by GitHub
parent c2357ec534
commit d8b6f8cf3b
7 changed files with 195 additions and 22 deletions
@@ -241,6 +241,40 @@ describe('GET /api/stacks/:stackName/files/content', () => {
await fs.unlink(bigPath);
}, 15000);
it('returns binary:true by default for a file with a NUL byte in the probe window', async () => {
const oddPath = path.join(stacksDir, STACK, 'force-text-default.txt');
await fs.writeFile(oddPath, Buffer.from('hello\0world rest is utf8 text', 'utf-8'));
try {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'force-text-default.txt' })
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.binary).toBe(true);
expect(res.body.content).toBeUndefined();
} finally {
await fs.unlink(oddPath);
}
});
it('returns the content as UTF-8 when ?force=text is set, even for files the binary heuristic rejects', async () => {
const oddPath = path.join(stacksDir, STACK, 'force-text-on.txt');
const raw = Buffer.from('hello\0world rest is utf8 text', 'utf-8');
await fs.writeFile(oddPath, raw);
try {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'force-text-on.txt', force: 'text' })
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.binary).toBe(false);
expect(typeof res.body.content).toBe('string');
expect(res.body.content).toBe(raw.toString('utf-8'));
} finally {
await fs.unlink(oddPath);
}
});
});
// ── GET /:stackName/files/download ────────────────────────────────────────────
+3 -2
View File
@@ -1420,10 +1420,11 @@ stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const forceText = req.query.force === 'text';
const startedAt = Date.now();
logFileDiag('read start', { stackName, relPath, nodeId: req.nodeId });
logFileDiag('read start', { stackName, relPath, nodeId: req.nodeId, forceText });
try {
const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath);
const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath, undefined, { forceText });
// ETag is the integer mtimeMs the file was stat'd with, so the matching
// PUT can compare millisecond-equal even though some filesystems return
// float mtimeMs.
+8 -2
View File
@@ -675,7 +675,8 @@ export class FileSystemService {
async readStackFile(
stackName: string,
relPath: string,
maxBytes: number = 2 * 1024 * 1024
maxBytes: number = 2 * 1024 * 1024,
opts: { forceText?: boolean } = {},
): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string; mtimeMs: number }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const mime = this.guessMime(safePath);
@@ -701,7 +702,12 @@ export class FileSystemService {
const buf = await fh.readFile();
if (isBinaryBuffer(buf)) {
// forceText bypasses the binary-detection heuristic so callers can
// recover from false positives (a UTF-8 file that happens to carry a
// NUL or a high non-printable ratio in its first 8 KB). The oversized
// branch above still applies because returning a multi-megabyte file
// as JSON-encoded text is wasteful regardless of the heuristic.
if (!opts.forceText && isBinaryBuffer(buf)) {
return { binary: true, oversized: false, size: stat.size, mime, mtimeMs };
}