From 12d6830424d24f5a2ed8056b53a12cc4b978ece2 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 16 Oct 2026 16:20:01 +0200 Subject: feat(assistant): tell a nameless file's kind from its content A script with no suffix staged as 'other' and was refused. A staged file the name says nothing about is now handed to file(1) once, and its mime type gives the kind, so a shell script is code and gets Explain and Review. The daemon makes its own call on the content. Co-Authored-By: Claude Opus 5.5 --- desktop/assistant/Assistant.qml | 32 +++++++++++++++++++++++++++++++- desktop/assistant/Files.js | 24 +++++++++++++++++++++--- desktop/assistant/README.md | 6 ++++-- desktop/assistant/test_files.js | 12 +++++++++++- 4 files changed, 67 insertions(+), 7 deletions(-) (limited to 'desktop') diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml index 977b4dd..0139ef9 100644 --- a/desktop/assistant/Assistant.qml +++ b/desktop/assistant/Assistant.qml @@ -105,10 +105,40 @@ Scope { root.staged = files; root.historyOpen = root.pickerOpen = false; root.open = true; + root.refine(); } function stage(path) { - if (!root.staged.some(f => f.path === path)) root.staged = root.staged.concat([Files.file(path)]); + if (root.staged.some(f => f.path === path)) return; + root.staged = root.staged.concat([Files.file(path)]); + root.refine(); + } + + // A file whose name says nothing (a script with no suffix) is "other" + // until file(1) has read it. One run for all of them; the daemon makes + // its own call on the content when the question is sent. + function refine() { + const unknown = root.staged.filter(f => f.kind === "other").map(f => f.path); + if (!unknown.length) return; + sniff.paths = unknown; + sniff.running = false; // a running Process ignores running = true + sniff.running = true; + } + + Process { + id: sniff + property var paths: [] + command: ["file", "-b", "--mime-type", "--"].concat(paths) + stdout: StdioCollector { + onStreamFinished: { + const mimes = text.trim().split("\n"); + root.staged = root.staged.map(f => { + const i = sniff.paths.indexOf(f.path); + return i < 0 || f.kind !== "other" ? f + : Object.assign({}, f, { kind: Files.fromMime(mimes[i] ?? "") }); + }); + } + } } function unstage(i) { const s = root.staged.slice(); diff --git a/desktop/assistant/Files.js b/desktop/assistant/Files.js index 825aed1..a75e693 100644 --- a/desktop/assistant/Files.js +++ b/desktop/assistant/Files.js @@ -14,9 +14,8 @@ // What a staged file is, for its chip and the actions offered on it. The // text and image lists match desktop-assistant's, which decides what chat // accepts; this only decides what the card offers. -// ponytail: the daemon also takes any text/* mime type; a file of such a -// type with a suffix not listed here reads as "other" and is not offered -// chat. Add the suffix when one shows up. +// A suffix not listed here reads as "other" at first; Assistant.qml then +// asks file(1) and fromMime() gives the kind. // Text splits into prose and data (text) and code or configuration (code), // so each gets the presets that fit it; the daemon sees one text kind. const TEXT = [".md", ".rst", ".txt", ".csv"]; @@ -49,6 +48,25 @@ function kind(path) { function chats(k) { return CHAT.includes(k); } +// For a file the name says nothing about (a script with no suffix), from +// the mime type `file -b --mime-type` reports. Prose and data are text; +// any other text type, scripts and source among them, is code. +const ARCHIVE_MIMES = ["application/zip", "application/x-tar", "application/gzip", + "application/x-xz", "application/x-bzip2", "application/zstd", + "application/x-7z-compressed", "application/x-rar", + "application/vnd.rar"]; + +function fromMime(m) { + if (["text/plain", "text/markdown", "text/csv"].includes(m)) return "text"; + if (m.startsWith("text/") || ["application/json", "application/xml", + "application/javascript"].includes(m)) return "code"; + if (["image/png", "image/jpeg", "image/gif", "image/webp"].includes(m)) return "image"; + if (m === "application/pdf") return "pdf"; + if (m.startsWith("video/")) return "video"; + if (ARCHIVE_MIMES.includes(m)) return "archive"; + return "other"; +} + // A dropped URL to a local path, or "" for anything that is not a local // file: file:///path, or file://localhost/path; any other host is remote. function fromUrl(url) { diff --git a/desktop/assistant/README.md b/desktop/assistant/README.md index 4715b12..8c74343 100644 --- a/desktop/assistant/README.md +++ b/desktop/assistant/README.md @@ -61,8 +61,10 @@ Notes: markers literally. - **File kinds are decided twice.** `Files.js` decides what the card - offers; the daemon decides what chat accepts. The lists match, but the - daemon also takes any `text/*` file, which the card may show as "other". + offers; the daemon decides what chat accepts. Both go by the name first + and by the content when the name says nothing: the card runs + `file -b --mime-type` on staged "other" files, the daemon reads the first + 4 KB. A script with no suffix is therefore code here and text there. - **`socketPath`** is a property so a check can point the canvas at a scratch daemon; the desktop uses the default. diff --git a/desktop/assistant/test_files.js b/desktop/assistant/test_files.js index 80a855d..f2a65ca 100644 --- a/desktop/assistant/test_files.js +++ b/desktop/assistant/test_files.js @@ -18,7 +18,7 @@ const assert = require("assert"); const src = fs.readFileSync(__dirname + "/Files.js", "utf8") .replace(/^\.pragma library$/m, ""); -const F = new Function(src + "\nreturn { kind, chats, fromUrl, file, name };")(); +const F = new Function(src + "\nreturn { kind, chats, fromUrl, file, name, fromMime };")(); const cases = { "/a/report.pdf": "pdf", "/a/Report.PDF": "pdf", "/a/x.png": "image", "/a/x.JPEG": "image", @@ -40,4 +40,14 @@ assert.strictEqual(F.fromUrl("file://otherhost/tmp/a.pdf"), ""); assert.strictEqual(F.fromUrl("file:///bad%E0%A4%A"), ""); // malformed escape assert.deepStrictEqual(F.file("/x/y/a b.pdf"), { path: "/x/y/a b.pdf", name: "a b.pdf", kind: "pdf" }); +// A file the name says nothing about is told by its mime type (file -b). +const mimes = { + "text/x-shellscript": "code", "text/x-script.python": "code", "text/x-c": "code", + "text/x-makefile": "code", "application/json": "code", "text/plain": "text", + "text/csv": "text", "text/markdown": "text", "image/png": "image", "application/pdf": "pdf", + "video/mp4": "video", "application/zip": "archive", "application/x-xz": "archive", + "application/octet-stream": "other", "application/x-executable": "other", "": "other", +}; +for (const [m, k] of Object.entries(mimes)) assert.strictEqual(F.fromMime(m), k, m); + console.log("ok"); -- cgit v1.2.3